asterism 0.7.0 → 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.
@@ -69,8 +69,13 @@ module Asterism
69
69
 
70
70
  # Opens a session (the arguments of Session.open) and wraps it in a
71
71
  # Connection. With a block: yields it, closes it when the block ends
72
- # (also on an exception) and returns the block's value.
73
- def self.open(locator = nil, interval: Runner::DEFAULT_INTERVAL, **opts)
72
+ # (also on an exception) and returns the block's value. credentials: a
73
+ # .tar from an Asterism console (Asterism::Credentials): mutual TLS with
74
+ # its certificates, to its router unless a locator is given.
75
+ def self.open(locator = nil, interval: Runner::DEFAULT_INTERVAL, credentials: nil, **opts)
76
+ if credentials
77
+ locator, opts[:config], = ::Asterism::Credentials.merge(credentials, locator, opts[:config])
78
+ end
74
79
  conn = Connection.new(Session.open(locator, **opts), interval: interval)
75
80
  return conn unless block_given?
76
81
  begin
@@ -358,6 +363,12 @@ module Asterism
358
363
  @session.closed?
359
364
  end
360
365
 
366
+ # True while the router is away and zenoh-c opens the connection
367
+ # again (a client session; what was declared comes back with it).
368
+ def reconnecting?
369
+ @session.reconnecting?
370
+ end
371
+
361
372
  def zid
362
373
  @session.zid
363
374
  end
@@ -3,5 +3,5 @@ module Asterism
3
3
  # is in asterism.gemspec (asterism-zenoh ~> 0.4.0 for 0.5.0 to 0.7.0:
4
4
  # the bindings did not change; see CHANGELOG.md). 0.7.0 speaks wire
5
5
  # protocol 1 only (Asterism::PROTOCOL, docs/protocol.md).
6
- VERSION = "0.7.0"
6
+ VERSION = "0.8.0"
7
7
  end
data/lib/asterism.rb CHANGED
@@ -12,12 +12,12 @@ require "asterism/zenoh"
12
12
  require_relative "asterism/version"
13
13
 
14
14
  # The same order as an mrbgem's mrblib (alphabetical).
15
- %w[asterism cdr future profile proxy ros].each do |f|
15
+ %w[asterism cdr credentials future profile proxy ros status].each do |f|
16
16
  require_relative "../mrblib/#{f}"
17
17
  end
18
18
  require_relative "asterism/cruby"
19
19
  # The CRuby API on top (blocks, a receiving thread, Enumerators,
20
20
  # pattern matching): CRuby only, see README "The CRuby API".
21
- %w[runner zenoh objects ros].each do |f|
21
+ %w[runner credentials zenoh objects ros].each do |f|
22
22
  require_relative "asterism/cruby/#{f}"
23
23
  end
data/mrblib/asterism.rb CHANGED
@@ -13,10 +13,12 @@
13
13
  # reply {"value" => v} or
14
14
  # {"error" => {"kind" => k, "class" => c, "message" => m}}
15
15
  # asterism/<node>/<app>/<object>/meta
16
- # get: reply {"methods" => [{"name" => n, "arity" => a}, ...]}
16
+ # get: reply {"methods" => [{"name" => n, "arity" => a,
17
+ # "args" => [...], "doc" => "..."}, ...], "doc" => "..."}
17
18
  # asterism/<node>/<app> liveliness: the application is up
18
19
  # asterism/<node>/<app>/<object> liveliness: the object is exposed
19
20
  # asterism/<node>/@profile get: reply the node's profile (profile.rb)
21
+ # asterism/<node>/<app>/@status get: reply the application's status (status.rb)
20
22
  #
21
23
  # Everything is polled: nothing runs behind the application's back. A call
22
24
  # that waits for its answer keeps polling meanwhile and answers the calls
@@ -240,16 +242,23 @@ module Asterism
240
242
  # Opens the connection. locator, mode: and listen: go to
241
243
  # Asterism::Zenoh::Session.open (client of a router by default; mode: :peer
242
244
  # with or without listen: for no router), and so do config: (a Hash of
243
- # "key/path" => value or a JSON5 String, e.g. TLS certificates) and
244
- # connect_timeout: (seconds) when they are given (CRuby's asterism-zenoh
245
- # only; the boards raise ArgumentError). node: is this machine's ID and
246
- # app: this application's name. check_timeout: how long to look for
247
- # another copy of the same <node>/<app> (seconds, CHECK_TIMEOUT). Raises
248
- # Disconnected when it cannot connect, and Error when the same
249
- # <node>/<app> is already on the network.
250
- def self.connect(locator, node:, app:, mode: nil, listen: nil, config: nil, connect_timeout: nil,
251
- check_timeout: nil)
245
+ # "key/path" => value or a JSON5 String, e.g. TLS certificates; the boards
246
+ # take the TLS keys only) and connect_timeout: (seconds; CRuby's
247
+ # asterism-zenoh only, the boards raise ArgumentError) when they are given.
248
+ # credentials: a .tar from an Asterism console (Asterism::Credentials, a
249
+ # path or a loaded one): its certificates go to zenoh (mutual TLS), and
250
+ # locator and node: default to its router and name. node: is this
251
+ # machine's ID and app: this application's name. check_timeout: how long
252
+ # to look for another copy of the same <node>/<app> (seconds,
253
+ # CHECK_TIMEOUT). Raises Disconnected when it cannot connect, and Error
254
+ # when the same <node>/<app> is already on the network.
255
+ def self.connect(locator = nil, node: nil, app:, mode: nil, listen: nil, config: nil, connect_timeout: nil,
256
+ check_timeout: nil, credentials: nil)
252
257
  raise Error, "already connected (Asterism.close first)" if connected?
258
+ if credentials
259
+ locator, config, creds = Credentials.merge(credentials, locator, config)
260
+ node ||= creds.name
261
+ end
253
262
  release
254
263
  node = check_name("node", node)
255
264
  app = check_name("app", app)
@@ -279,7 +288,8 @@ module Asterism
279
288
  @session = s
280
289
  @node = node
281
290
  @app = app
282
- @objects = {} # name => [object, {method name => arity}, token, caller keyword or nil]
291
+ @objects = {} # name => [object, {method name => arity}, token, caller keyword or nil,
292
+ # {method name => meta fields}, the object's doc or nil]
283
293
  @alive = {} # "node/app/object" => node (from liveliness)
284
294
  @apps = {} # "node/app" => node (from liveliness)
285
295
  @proxies = {}
@@ -287,9 +297,13 @@ module Asterism
287
297
  @lost = nil
288
298
  @known = [] # the other nodes told to on_join so far
289
299
  @known_set = {} # the same, as node => true
300
+ @known_apps = {} # "node/app" => true: the other applications told to on_app_join
290
301
  @node_list = [node] # what nodes returns (this node first), rebuilt on a change
291
302
  @nodes_changed = false # liveliness changed since tell_nodes last compared
303
+ @was_reconnecting = false
304
+ @status_put = nil
292
305
  tell_profile # for those who follow the profiles (the reply is the queryable's)
306
+ tell_status(true) # a status set before connecting (status.rb)
293
307
  self
294
308
  end
295
309
 
@@ -320,6 +334,20 @@ module Asterism
320
334
  true
321
335
  end
322
336
 
337
+ # True while the connection is lost and the Zenoh binding opens it again
338
+ # by itself (a client of a router: zenoh-pico built with reconnection on
339
+ # the boards, zenoh-c on CRuby). connected? stays true meanwhile, and once
340
+ # the router is back everything declared before is there again: the
341
+ # application token, the exposed objects, the liveliness watch, and what
342
+ # the application declared on Asterism.session. Meanwhile the other nodes
343
+ # leave (on_leave) and join again after it, calls raise Disconnected or
344
+ # time out, and puts reach nobody.
345
+ def self.reconnecting?
346
+ s = @session
347
+ return false if s.nil?
348
+ s.respond_to?(:reconnecting?) ? s.reconnecting? : false
349
+ end
350
+
323
351
  def self.node_id
324
352
  @node
325
353
  end
@@ -338,8 +366,11 @@ module Asterism
338
366
  def self.close
339
367
  release
340
368
  @known = nil
369
+ @known_apps = nil
341
370
  @on_join = nil
342
371
  @on_leave = nil
372
+ @on_app_join = nil
373
+ @on_app_leave = nil
343
374
  nil
344
375
  end
345
376
 
@@ -365,6 +396,8 @@ module Asterism
365
396
  @queryable = nil
366
397
  @watch = nil
367
398
  @profile_queryable = nil
399
+ @status_queryable = nil
400
+ @status_put = nil
368
401
  end
369
402
 
370
403
  # The Zenoh session of the connection (an Asterism::Zenoh::Session), for
@@ -423,21 +456,39 @@ module Asterism
423
456
 
424
457
  # Makes obj callable from other machines as <node>/<app>/<name>. Only the
425
458
  # listed methods can be called. methods: is an Array of names, or a Hash
426
- # name => number of arguments (checked before calling; -1 for any).
459
+ # name => number of arguments (checked before calling; -1 for any), or
460
+ # name => {args: [names], doc: "text"} (docs/protocol.md 5): the
461
+ # arguments' names (their number is the number checked, unless arity:
462
+ # says otherwise) and what the method does, both told in the meta for
463
+ # the readers that show them (asterism mcp, the command line). doc: a
464
+ # text about the object itself, also in the meta.
427
465
  # caller: a keyword name (caller: :from): each call passes the calling
428
466
  # node's ID (Asterism.current_call.from) to the method as that keyword,
429
467
  # and a call that brings the keyword itself is refused (an "arguments"
430
468
  # error), so the value is always the one Asterism took from the call.
431
- def self.expose(name, obj, methods:, caller: nil)
469
+ def self.expose(name, obj, methods:, caller: nil, doc: nil)
432
470
  s = session!
433
471
  name = check_name("object", name)
434
472
  table = {}
473
+ info = {}
435
474
  if methods.is_a?(Hash)
436
- methods.each { |m, n| table[m.to_s] = n.to_i }
475
+ methods.each do |m, n|
476
+ if n.is_a?(Hash)
477
+ d = method_doc(m, n)
478
+ table[m.to_s] = d[0]
479
+ info[m.to_s] = d[1]
480
+ else
481
+ table[m.to_s] = n.to_i
482
+ end
483
+ end
437
484
  else
438
485
  methods.each { |m| table[m.to_s] = -1 }
439
486
  end
440
487
  raise ArgumentError, "no method to expose" if table.empty?
488
+ unless doc.nil?
489
+ raise ArgumentError, "doc: expects a String, got #{doc.class}" unless doc.is_a?(String)
490
+ raise ArgumentError, "doc: at most #{DOC_MAX} bytes" if doc.bytesize > DOC_MAX
491
+ end
441
492
  table.each_key do |m|
442
493
  raise ArgumentError, "#{obj.class} has no public method #{m}" unless obj.respond_to?(m)
443
494
  end
@@ -451,12 +502,60 @@ module Asterism
451
502
  end
452
503
  unexpose(name)
453
504
  token = s.liveliness("#{ROOT}/#{@node}/#{@app}/#{name}")
454
- @objects[name] = [obj, table, token, kw]
505
+ @objects[name] = [obj, table, token, kw, info, doc]
455
506
  "#{@node}/#{@app}/#{name}"
456
507
  rescue Asterism::Zenoh::Error => e
457
508
  raise Disconnected, e.message
458
509
  end
459
510
 
511
+ # The longest doc text (bytes) and the most argument names a method may
512
+ # tell in its meta (docs/protocol.md 5).
513
+ DOC_MAX = 2048
514
+ ARGS_MAX = 16
515
+
516
+ # A method's {args:, doc:, arity:} at expose: [the number of arguments
517
+ # checked, its meta fields {"args", "doc"}].
518
+ # @api private
519
+ def self.method_doc(m, h)
520
+ args = nil
521
+ doc = nil
522
+ arity = nil
523
+ h.each do |k, v|
524
+ ks = k.to_s
525
+ if ks == "args" then args = v
526
+ elsif ks == "doc" then doc = v
527
+ elsif ks == "arity" then arity = v
528
+ else raise ArgumentError, "#{m}: unknown key #{ks} (args:, doc:, arity:)"
529
+ end
530
+ end
531
+ out = {}
532
+ unless args.nil?
533
+ raise ArgumentError, "#{m}: args: expects an Array of names" unless args.is_a?(Array)
534
+ raise ArgumentError, "#{m}: args: at most #{ARGS_MAX} names" if args.size > ARGS_MAX
535
+ names = []
536
+ args.each do |a|
537
+ raise ArgumentError, "#{m}: args: expects names, got #{a.inspect}" unless a.is_a?(String) || a.is_a?(Symbol)
538
+ a = a.to_s
539
+ raise ArgumentError, "#{m}: args: an empty name" if a.empty? || a.bytesize > 64
540
+ names << a
541
+ end
542
+ out["args"] = names
543
+ end
544
+ unless doc.nil?
545
+ raise ArgumentError, "#{m}: doc: expects a String" unless doc.is_a?(String)
546
+ raise ArgumentError, "#{m}: doc: at most #{DOC_MAX} bytes" if doc.bytesize > DOC_MAX
547
+ out["doc"] = doc
548
+ end
549
+ n = if !arity.nil? then arity.to_i
550
+ elsif !args.nil? then args.size
551
+ else -1
552
+ end
553
+ if n >= 0 && !args.nil? && args.size != n
554
+ raise ArgumentError, "#{m}: #{args.size} names in args: for #{n} arguments"
555
+ end
556
+ [n, out]
557
+ end
558
+
460
559
  def self.unexpose(name)
461
560
  entry = @objects ? @objects.delete(name.to_s) : nil
462
561
  entry[2].close if entry
@@ -517,6 +616,88 @@ module Asterism
517
616
  self
518
617
  end
519
618
 
619
+ # on_app_join { |node, app| }: an application of another node appeared
620
+ # (its token asterism/<node>/<app>); on_app_leave { |node, app| }: it is
621
+ # gone. A node may run several applications: each comes and goes on its
622
+ # own, and the node stays (on_join / on_leave) while one of them or an
623
+ # object is there. The applications there already join on the first
624
+ # polls after connecting; when the connection is lost, every application
625
+ # known until then leaves. Called from Asterism.poll, as on_join;
626
+ # Asterism.close forgets them.
627
+ def self.on_app_join(&blk)
628
+ raise ArgumentError, "on_app_join needs a block" unless blk
629
+ (@on_app_join ||= []) << blk
630
+ self
631
+ end
632
+
633
+ def self.on_app_leave(&blk)
634
+ raise ArgumentError, "on_app_leave needs a block" unless blk
635
+ (@on_app_leave ||= []) << blk
636
+ self
637
+ end
638
+
639
+ def self.off_app_join(blk)
640
+ @on_app_join.delete(blk) if @on_app_join
641
+ self
642
+ end
643
+
644
+ def self.off_app_leave(blk)
645
+ @on_app_leave.delete(blk) if @on_app_leave
646
+ self
647
+ end
648
+
649
+ # Compares the applications alive now with those told before and calls
650
+ # the on_app_* blocks for the difference (this application left out).
651
+ # @api private
652
+ def self.tell_apps
653
+ known = @known_apps
654
+ return if known.nil?
655
+ now = {}
656
+ if @session
657
+ own = "#{@node}/#{@app}"
658
+ keys = @apps.keys
659
+ i = 0
660
+ while i < keys.size
661
+ now[keys[i]] = true unless keys[i] == own
662
+ i += 1
663
+ end
664
+ end
665
+ joined = []
666
+ left = []
667
+ keys = now.keys
668
+ i = 0
669
+ while i < keys.size
670
+ joined << keys[i] unless known[keys[i]]
671
+ i += 1
672
+ end
673
+ keys = known.keys
674
+ i = 0
675
+ while i < keys.size
676
+ left << keys[i] unless now[keys[i]]
677
+ i += 1
678
+ end
679
+ @known_apps = @session ? now : nil
680
+ tell_apps_each("on_app_join", @on_app_join, joined)
681
+ tell_apps_each("on_app_leave", @on_app_leave, left)
682
+ end
683
+
684
+ # @api private
685
+ def self.tell_apps_each(where, blocks, names)
686
+ return if blocks.nil? || names.empty?
687
+ i = 0
688
+ while i < names.size
689
+ cut = names[i].index("/")
690
+ node = names[i][0, cut]
691
+ app = names[i][cut + 1, names[i].size]
692
+ j = 0
693
+ while j < blocks.size
694
+ tell(where, blocks[j], node, app)
695
+ j += 1
696
+ end
697
+ i += 1
698
+ end
699
+ end
700
+
520
701
  # Compares the nodes alive now with those told before and calls the
521
702
  # blocks for the difference.
522
703
  # @api private
@@ -527,6 +708,7 @@ module Asterism
527
708
  # the connection is neither new nor lost): nothing can have changed.
528
709
  return unless @nodes_changed
529
710
  @nodes_changed = false
711
+ tell_apps
530
712
  now = []
531
713
  now_set = {}
532
714
  if @session
@@ -580,10 +762,15 @@ module Asterism
580
762
  end
581
763
  end
582
764
 
583
- # One block for one node (CRuby routes what it raises to on_error).
765
+ # One block for one node, or one application (CRuby routes what it
766
+ # raises to on_error).
584
767
  # @api private
585
- def self.tell(_where, blk, node)
586
- blk.call(node)
768
+ def self.tell(_where, blk, node, app = nil)
769
+ if app.nil?
770
+ blk.call(node)
771
+ else
772
+ blk.call(node, app)
773
+ end
587
774
  end
588
775
 
589
776
  # @api private
@@ -594,13 +781,36 @@ module Asterism
594
781
  lost("the connection was lost")
595
782
  return false
596
783
  end
784
+ back_again(s)
597
785
  answer_calls
598
786
  answer_profile
787
+ answer_status
599
788
  follow_liveliness
600
789
  !@session.nil?
601
790
  rescue Asterism::Zenoh::Error => e
602
- lost(e.message)
603
- false
791
+ lost_if_closed(e.message)
792
+ !@session.nil?
793
+ end
794
+
795
+ # After the binding opened a lost connection again: tell the profile and
796
+ # the status again (a put made while the router was away reached nobody).
797
+ # @api private
798
+ def self.back_again(s)
799
+ now = s.respond_to?(:reconnecting?) && s.reconnecting?
800
+ was = @was_reconnecting
801
+ @was_reconnecting = now
802
+ return unless was && !now
803
+ tell_profile
804
+ tell_status(true)
805
+ end
806
+
807
+ # A Zenoh error during a call or a poll: the connection is gone only when
808
+ # the session says it is closed. While the binding reconnects, the
809
+ # session (and everything declared on it) stays.
810
+ # @api private
811
+ def self.lost_if_closed(reason)
812
+ s = @session
813
+ lost(reason) if s.nil? || s.closed?
604
814
  end
605
815
 
606
816
  # Polls until the block is true. Answers incoming calls meanwhile.
@@ -764,7 +974,7 @@ module Asterism
764
974
  # The caller's node ID is the key's last chunk (docs/protocol.md 4.1).
765
975
  g = s.get("#{ROOT}/#{path}/call/#{@node}", timeout_ms: timeout_ms, payload: payload)
766
976
  rescue Asterism::Zenoh::Error => e
767
- lost(e.message)
977
+ lost_if_closed(e.message)
768
978
  raise Disconnected, e.message
769
979
  end
770
980
  Future.new(path, method, g, now_ms + timeout_ms, timeout_ms)
@@ -797,7 +1007,7 @@ module Asterism
797
1007
  begin
798
1008
  g = session!.get("#{ROOT}/#{path}/meta", timeout_ms: timeout_ms)
799
1009
  rescue Asterism::Zenoh::Error => e
800
- lost(e.message)
1010
+ lost_if_closed(e.message)
801
1011
  raise Disconnected, e.message
802
1012
  end
803
1013
  f = Future.new(path, "meta", g, now_ms + timeout_ms, timeout_ms)
@@ -889,8 +1099,16 @@ module Asterism
889
1099
  entry = @objects[name]
890
1100
  return nil unless entry
891
1101
  list = []
892
- entry[1].each { |m, n| list << { "name" => m, "arity" => n } }
893
- { "methods" => list }
1102
+ info = entry[4] || {}
1103
+ entry[1].each do |m, n|
1104
+ e = { "name" => m, "arity" => n }
1105
+ extra = info[m]
1106
+ extra.each { |k, v| e[k] = v } if extra
1107
+ list << e
1108
+ end
1109
+ out = { "methods" => list }
1110
+ out["doc"] = entry[5] if entry[5]
1111
+ out
894
1112
  end
895
1113
 
896
1114
  MALFORMED = "malformed call (expected a map {\"method\" => name, \"args\" => [...], \"kwargs\" => {...}})"
@@ -0,0 +1,197 @@
1
+ # The credentials of one node or router as one file: the .tar an Asterism
2
+ # console hands out when it issues a certificate ("Save all (.tar)" on the
3
+ # certificate page; the archive is made in the browser that made the key).
4
+ # It holds, at its top or in one directory:
5
+ #
6
+ # <name>.key the private key (PKCS#8 PEM)
7
+ # <name>.pem the certificate (its common name is <name>)
8
+ # ca.pem the CA that signed it (and the router's certificate)
9
+ # zenoh.json5 an example configuration (for zenohd or another client)
10
+ # asterism.json {"name": ..., "router": "tls/host:port", "not_after": ..., ...}
11
+ #
12
+ # Asterism.connect(credentials: "/home/asterism/alice.tar", app: "game")
13
+ #
14
+ # The certificates go to zenoh as base64 (transport/link/tls/*_base64) with
15
+ # enable_mtls, and the router's locator and the node ID come from
16
+ # asterism.json unless given. This is the portable part, in plain Ruby for
17
+ # the boards (mruby / PicoRuby: no Regexp, no pack, no File.binread): the
18
+ # file is read whole and the tar taken apart with byteslice / getbyte.
19
+ # CRuby replaces the reading and adds checks (lib/asterism/cruby/credentials.rb:
20
+ # the standard library's tar reader, file modes, the key pair).
21
+ module Asterism
22
+ class Credentials
23
+ # What a credentials file is missing or holds wrong.
24
+ class Error < ::Asterism::Error; end
25
+
26
+ TLS = "transport/link/tls"
27
+ # The largest file read (the real ones are a few KB).
28
+ MAX_SIZE = 1024 * 1024
29
+ BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
30
+
31
+ attr_reader :path, :name, :kind, :router, :not_after, :key_pem, :cert_pem, :ca_pem, :info
32
+
33
+ # Reads path (a String of a .tar). warn: false skips the check of who
34
+ # may read the file (CRuby; the boards have no file modes).
35
+ def self.load(path, warn: true)
36
+ new(path, warn: warn)
37
+ end
38
+
39
+ # A credentials file (a path or a Credentials) and the arguments of a
40
+ # connection -> [locator, config Hash, credentials]. The locator given
41
+ # wins over asterism.json's router; config given (a Hash) is laid over
42
+ # the TLS keys. @api private
43
+ def self.merge(credentials, locator, config)
44
+ creds = credentials.is_a?(Credentials) ? credentials : load(credentials)
45
+ if config.is_a?(String)
46
+ raise ArgumentError, "credentials: and a config String cannot be combined; pass config: as a Hash"
47
+ end
48
+ merged = creds.zenoh_config
49
+ (config || {}).each { |k, v| merged[k] = v }
50
+ [locator || creds.router, merged, creds]
51
+ end
52
+
53
+ # {basename => content} of the regular files of a ustar archive (data,
54
+ # a String of its bytes), at its top or in one directory. @api private
55
+ def self.untar(data, path = "the file")
56
+ files = {}
57
+ pos = 0
58
+ total = data.bytesize
59
+ while pos + 512 <= total
60
+ break if data.getbyte(pos) == 0 # the end: blocks of zeros
61
+ name = field(data, pos, 100)
62
+ prefix = field(data, pos + 345, 155)
63
+ size_text = field(data, pos + 124, 12).strip
64
+ type = data.getbyte(pos + 156)
65
+ size = size_text.empty? ? 0 : size_text.to_i(8)
66
+ pos += 512
67
+ raise Error, "#{path}: not a tar file (a member runs past the end)" if pos + size > total
68
+ if type == 48 || type == 0 # '0' or NUL: a regular file
69
+ full = prefix.empty? ? name : "#{prefix}/#{name}"
70
+ base = full.split("/").last.to_s
71
+ raise Error, "#{path}: #{base} is in it twice" if files.key?(base)
72
+ files[base] = data.byteslice(pos, size) unless base.empty?
73
+ end
74
+ pos += (size + 511) / 512 * 512
75
+ end
76
+ raise Error, "#{path}: not a tar file with credentials in it" if files.empty?
77
+ files
78
+ end
79
+
80
+ # The text of a NUL-terminated header field. @api private
81
+ def self.field(data, pos, len)
82
+ n = 0
83
+ n += 1 while n < len && data.getbyte(pos + n) != 0
84
+ data.byteslice(pos, n).to_s
85
+ end
86
+
87
+ # base64 of s without line breaks (CRuby's [s].pack("m0")). @api private
88
+ def self.base64(s)
89
+ out = ""
90
+ n = s.bytesize
91
+ i = 0
92
+ while i < n
93
+ b0 = s.getbyte(i)
94
+ b1 = i + 1 < n ? s.getbyte(i + 1) : 0
95
+ b2 = i + 2 < n ? s.getbyte(i + 2) : 0
96
+ v = (b0 << 16) | (b1 << 8) | b2
97
+ out << BASE64[(v >> 18) & 63] << BASE64[(v >> 12) & 63]
98
+ out << (i + 1 < n ? BASE64[(v >> 6) & 63] : "=")
99
+ out << (i + 2 < n ? BASE64[v & 63] : "=")
100
+ i += 3
101
+ end
102
+ out
103
+ end
104
+
105
+ # The string fields of asterism.json without a JSON library (only for
106
+ # a build that has none). @api private
107
+ def self.json_strings(text)
108
+ h = {}
109
+ %w[format name kind router serial not_after fingerprint console issued_at].each do |k|
110
+ key = "\"#{k}\""
111
+ i = text.index(key)
112
+ next unless i
113
+ rest = text[i + key.size, text.size - i - key.size].to_s
114
+ colon = rest.index(":")
115
+ next unless colon
116
+ q1 = colon + 1
117
+ q1 += 1 while rest[q1] == " "
118
+ next unless rest[q1] == "\""
119
+ q2 = rest.index("\"", q1 + 1)
120
+ next unless q2
121
+ h[k] = rest[q1 + 1, q2 - q1 - 1]
122
+ end
123
+ h
124
+ end
125
+
126
+ def initialize(path, warn: true)
127
+ @path = path.to_s
128
+ check_mode if warn
129
+ files = read_tar
130
+ @info = parse_info(files["asterism.json"])
131
+ keys = files.keys.select { |f| f.end_with?(".key") }
132
+ raise Error, "#{@path}: no private key (<name>.key) in it" if keys.empty?
133
+ raise Error, "#{@path}: more than one private key in it (#{keys.sort.join(', ')})" if keys.size > 1
134
+ @name = @info["name"].to_s
135
+ @name = keys.first[0, keys.first.size - 4] if @name.empty?
136
+ @key_pem = files["#{@name}.key"] or raise Error, "#{@path}: the key is not named after #{@name} (#{keys.first})"
137
+ @cert_pem = files["#{@name}.pem"] or raise Error, "#{@path}: no certificate #{@name}.pem in it"
138
+ @ca_pem = files["ca.pem"] or raise Error, "#{@path}: no ca.pem in it"
139
+ @kind = @info["kind"]
140
+ @router = @info["router"].to_s.empty? ? nil : @info["router"].to_s
141
+ @not_after = @info["not_after"]
142
+ check_pair
143
+ end
144
+
145
+ # The zenoh configuration keys (a Hash for Session.open's config:):
146
+ # the CA, the certificate and the key as base64, and mutual TLS.
147
+ def zenoh_config
148
+ {
149
+ "#{TLS}/root_ca_certificate_base64" => b64(@ca_pem),
150
+ "#{TLS}/connect_certificate_base64" => b64(@cert_pem),
151
+ "#{TLS}/connect_private_key_base64" => b64(@key_pem),
152
+ "#{TLS}/enable_mtls" => true
153
+ }
154
+ end
155
+
156
+ def inspect
157
+ "#<Asterism::Credentials #{@name} #{@router || '(no router)'}#{@not_after ? " until #{@not_after}" : ''}>"
158
+ end
159
+ alias to_s inspect
160
+
161
+ private
162
+
163
+ def b64(pem)
164
+ Credentials.base64(pem)
165
+ end
166
+
167
+ # The boards have no file modes (CRuby warns like ssh).
168
+ def check_mode; end
169
+
170
+ # The key pair is checked by CRuby (openssl); on the boards the router
171
+ # refuses a wrong one.
172
+ def check_pair; end
173
+
174
+ def read_tar
175
+ data = begin
176
+ ::File.open(@path, "r") { |f| f.read }
177
+ rescue StandardError => e
178
+ raise Error, "#{@path}: #{e.message}"
179
+ end
180
+ raise Error, "#{@path}: cannot be read" if data.nil?
181
+ raise Error, "#{@path}: too large for a credentials file" if data.bytesize > MAX_SIZE
182
+ Credentials.untar(data, @path)
183
+ end
184
+
185
+ def parse_info(text)
186
+ return {} if text.nil?
187
+ return Credentials.json_strings(text) unless Object.const_defined?(:JSON)
188
+ h = ::JSON.parse(text)
189
+ raise Error, "#{@path}: asterism.json is not an object" unless h.is_a?(Hash)
190
+ h
191
+ rescue Error
192
+ raise
193
+ rescue StandardError => e
194
+ raise Error, "#{@path}: asterism.json: #{e.message}"
195
+ end
196
+ end
197
+ end
data/mrblib/profile.rb CHANGED
@@ -282,7 +282,7 @@ module Asterism
282
282
  begin
283
283
  g = session!.get("#{ROOT}/#{path}", timeout_ms: ms, params: icon ? nil : "icon=0")
284
284
  rescue Asterism::Zenoh::Error => e
285
- lost(e.message)
285
+ lost_if_closed(e.message)
286
286
  raise Disconnected, e.message
287
287
  end
288
288
  Future.new(path, "profile", g, now_ms + ms, ms)