exsys 0.5 → 1.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,165 @@
1
+ # A model of the ExSYS managed hub's serial protocol, faithful enough
2
+ # to drive ExSYS::ManagedUSB end to end without hardware.
3
+ #
4
+ # The wire encoding here is written out independently of the library's
5
+ # own pack/unpack, so that a disagreement between the two shows up as a
6
+ # test failure rather than both sharing the same mistake.
7
+ class FakeHub
8
+ DEFAULT_PASSWORD = 'pass'.ljust(8)
9
+
10
+ attr_reader :log, :opens, :locks, :line, :speed, :mode, :lock_path
11
+ attr_accessor :password, :silent, :lockable, :garbage, :lock_error,
12
+ :ident
13
+
14
+ # @param state [Integer] initial port bitmap
15
+ # @param path [String] file backing the state, so that separate
16
+ # processes share one hub
17
+ # @param lock [String] file used for a real flock, so that the
18
+ # locking is genuinely exercised
19
+ # @param delay [Float] pause inside a write, widening the
20
+ # window a concurrent process could slip
21
+ # into
22
+ # @param ports [Integer] how many ports this hub has
23
+ # @param width [Integer] hex digits in its state word. Real hubs
24
+ # answer wider than their port count: a
25
+ # 16-port unit answers eight, and the ports
26
+ # it does not have read as 1.
27
+ def initialize(password: DEFAULT_PASSWORD, state: 0x0000,
28
+ ports: 16, width: 8,
29
+ path: nil, lock: nil, delay: 0)
30
+ @password = password
31
+ @state = state
32
+ @flash = state
33
+ @path = path
34
+ @lock_path = lock
35
+ @delay = delay
36
+ @log = []
37
+ @opens = 0
38
+ @locks = 0
39
+ @silent = false # hub answers nothing at all
40
+ @lockable = true # platform allows locking the line
41
+ @garbage = nil # hub answers this instead, when set
42
+ @lock_error = nil # raised by flock; for the propagation test
43
+ @ports = ports
44
+ @width = width
45
+ @ident = format('CENTOS0005%02dv02', ports) # nil = refuse ?Q
46
+
47
+ # Attach to the hub the file already describes, so that a test
48
+ # can inspect what its subprocesses did; seed it otherwise.
49
+ if @path && File.exist?(@path) then load
50
+ else save
51
+ end
52
+ end
53
+
54
+ # Build from the environment, for the executable's subprocesses.
55
+ def self.from_env(env = ENV)
56
+ new(path: env['EXSYS_TEST_HUB'],
57
+ lock: env['EXSYS_TEST_LOCK'],
58
+ delay: env['EXSYS_TEST_DELAY'].to_f).tap do |hub|
59
+ hub.silent = !env['EXSYS_TEST_SILENT'].nil?
60
+ hub.lockable = env['EXSYS_TEST_NOLOCK'].nil?
61
+ hub.garbage = env['EXSYS_TEST_GARBAGE']
62
+ end
63
+ end
64
+
65
+ def state = (load; @state)
66
+ def flash = (load; @flash)
67
+
68
+ # Ports currently powered, as a sorted list -- the oracle the tests
69
+ # compare against.
70
+ def ports_on
71
+ 1.upto(@ports).select {|p| state & (1 << (p-1)) != 0 }
72
+ end
73
+
74
+ # The power-on state the hub would come back to.
75
+ def flash_ports
76
+ 1.upto(@ports).select {|p| flash & (1 << (p-1)) != 0 }
77
+ end
78
+
79
+ # Bits for ports this hub does not have. A real one reports them
80
+ # set, which is why a client must write back what it read.
81
+ def absent = ((1 << (@width * 4)) - 1) & ~real
82
+
83
+ def real = (1 << @ports) - 1
84
+
85
+ # Record how the line was opened, so a test can check the library
86
+ # asks for the device and the speed the hub actually needs.
87
+ def opened(line, speed, mode)
88
+ @line, @speed, @mode = line, speed, mode
89
+ @opens += 1
90
+ end
91
+
92
+ # Called by each opened line before it locks. The lock handle
93
+ # itself belongs to the line, not to the hub, so that two threads
94
+ # hold two of them and genuinely contend.
95
+ def lock_attempted
96
+ raise @lock_error if @lock_error # must NOT be swallowed
97
+ raise Errno::ENOTSUP unless @lockable # platform refusal
98
+ @locks += 1
99
+ end
100
+
101
+ # Answer one command, as the hub would.
102
+ def command(cmd)
103
+ @log << cmd
104
+ return '' if @silent
105
+ return @garbage if @garbage
106
+
107
+ load
108
+ reply = dispatch(cmd)
109
+ save
110
+ reply
111
+ end
112
+
113
+ private
114
+
115
+ def dispatch(cmd)
116
+ code = cmd[0, 2]
117
+ args = cmd[2..].to_s
118
+
119
+ # GP and ?Q are the two commands the hub answers without a
120
+ # password, both with a bare payload rather than a G/E status.
121
+ return encode(@state | absent) if code == 'GP'
122
+ return @ident || 'E01' if code == '?Q'
123
+
124
+ return 'E01' unless args.start_with?(@password)
125
+ rest = args[@password.size..]
126
+
127
+ sleep @delay if @delay.positive?
128
+
129
+ case code
130
+ when 'SP' then @state = decode(rest) & real ; 'G'
131
+ when 'FP' then @state = @flash = decode(rest) & real ; 'G'
132
+ when 'WP' then @flash = @state ; 'G'
133
+ when 'RD' then @state = @flash = 0
134
+ @password = DEFAULT_PASSWORD ; 'G'
135
+ when 'RH' then @state = @flash ; nil
136
+ when 'CP' then @password = rest ; 'G'
137
+ else 'E02'
138
+ end
139
+ end
140
+
141
+ # Little-endian, any width, spelled out rather than packed. Upper
142
+ # case because that is what a real hub answers -- observed on an
143
+ # ExSYS 16-port unit, which replies to GP with "C4FFFFFF".
144
+ def encode(v)
145
+ (@width / 2).times.map {|i| format('%02X', (v >> (8 * i)) & 0xff) }
146
+ .join
147
+ end
148
+
149
+ def decode(str)
150
+ str.scan(/\h\h/).each_with_index
151
+ .sum {|byte, i| byte.to_i(16) << (8 * i) }
152
+ end
153
+
154
+ def load
155
+ return if @path.nil? || !File.exist?(@path)
156
+ @state, @flash, @password = File.read(@path).split("\n", 3)
157
+ @state = @state.to_i(16)
158
+ @flash = @flash.to_i(16)
159
+ end
160
+
161
+ def save
162
+ return if @path.nil?
163
+ File.write(@path, "%04x\n%04x\n%s" % [ @state, @flash, @password ])
164
+ end
165
+ end
@@ -0,0 +1,57 @@
1
+ # Test double for the `uart` gem.
2
+ #
3
+ # test/helper.rb puts this directory on the load path ahead of the real
4
+ # gem, so `require "uart"` inside ExSYS::ManagedUSB picks this up and
5
+ # the suite runs against FakeHub. That is what lets the tests run with
6
+ # no hub attached and with neither the uart nor the termios gem
7
+ # installed.
8
+ require_relative 'fake_hub'
9
+
10
+ module UART
11
+ class << self
12
+ attr_writer :hub
13
+
14
+ # In-process tests set the hub directly; the subprocesses the
15
+ # executable's tests spawn build theirs from the environment.
16
+ def hub = @hub ||= FakeHub.from_env
17
+
18
+ def reset! = @hub = nil
19
+ end
20
+
21
+ # Stands in for the File that UART.open normally yields. Each one
22
+ # carries its own lock handle, as a real open would, so that two of
23
+ # them contend instead of sharing one.
24
+ class Serial
25
+ def initialize(hub)
26
+ @hub = hub
27
+ @lock = hub.lock_path &&
28
+ File.open(hub.lock_path, File::RDWR|File::CREAT, 0o600)
29
+ end
30
+
31
+ def flock(mode)
32
+ @hub.lock_attempted
33
+ @lock ? @lock.flock(mode) : 0
34
+ end
35
+
36
+ def write(cmd) = @reply = @hub.command(cmd.chomp("\r"))
37
+
38
+ # A real hub ends every reply CR LF -- seen in the hex off the
39
+ # wire -- so a read to the terminator takes the whole line.
40
+ def gets(_sep = "\n")
41
+ @reply.nil? ? nil : "#{@reply}\r\n"
42
+ end
43
+ def close = @lock&.close
44
+ end
45
+
46
+ def self.open(line, speed = 9600, mode = '8N1')
47
+ hub.opened(line, speed, mode)
48
+ serial = Serial.new(hub)
49
+ return serial unless block_given?
50
+
51
+ begin
52
+ yield serial
53
+ ensure
54
+ serial.close
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,325 @@
1
+ require_relative 'helper'
2
+
3
+ # Behaviour of the bin/exsys-usb executable, run as a subprocess so
4
+ # that its exit status -- what a calling script actually reads -- is
5
+ # part of what is asserted.
6
+ class TestExsysUsb < Minitest::Test
7
+ include CLI
8
+
9
+ ## Switching #########################################################
10
+
11
+ def test_on_and_off_reach_the_hub
12
+ _, _, st = exsys_usb('on', '1', '2')
13
+ assert_predicate st, :success?
14
+ assert_equal [ 1, 2 ], hub.ports_on
15
+
16
+ exsys_usb('off', '1')
17
+ assert_equal [ 2 ], hub.ports_on
18
+ end
19
+
20
+ def test_bare_on_and_off_cover_every_port
21
+ exsys_usb('on')
22
+ assert_equal ExSYS::ManagedUSB::PORTS, hub.ports_on
23
+ exsys_usb('off')
24
+ assert_empty hub.ports_on
25
+ end
26
+
27
+ def test_toggle
28
+ exsys_usb('on', '1', '3')
29
+ exsys_usb('toggle', '3', '4')
30
+ assert_equal [ 1, 4 ], hub.ports_on
31
+ end
32
+
33
+ def test_set_pairs
34
+ exsys_usb('on', '5')
35
+ _, _, st = exsys_usb('set', '3:on', '5:off')
36
+ assert_predicate st, :success?
37
+ assert_equal [ 3 ], hub.ports_on
38
+ end
39
+
40
+ def test_set_accepts_every_documented_spelling
41
+ exsys_usb('set', '1:on', '2:ON', '3:true', '4:t', '5:1')
42
+ assert_equal [ 1, 2, 3, 4, 5 ], hub.ports_on
43
+ exsys_usb('set', '1:off', '2:OFF', '3:false', '4:f', '5:0')
44
+ assert_empty hub.ports_on
45
+ end
46
+
47
+ def test_default_flag_forces_the_unlisted_ports
48
+ exsys_usb('on', '8')
49
+ exsys_usb('-D', 'false', 'set', '3:on')
50
+ assert_equal [ 3 ], hub.ports_on
51
+ end
52
+
53
+ def test_commit_saves_the_power_on_state
54
+ exsys_usb('on', '1')
55
+ exsys_usb('commit')
56
+ exsys_usb('on', '2')
57
+ assert_equal [ 1, 2 ], hub.ports_on
58
+ assert_equal [ 1 ], hub.flash_ports
59
+ end
60
+
61
+ def test_commit_flag_writes_through
62
+ exsys_usb('-c', 'on', '4')
63
+ exsys_usb('on', '5')
64
+ assert_equal [ 4, 5 ], hub.ports_on
65
+ assert_equal [ 4 ], hub.flash_ports
66
+ end
67
+
68
+ # restore issues RD, the hub's factory reset -- not the inverse of
69
+ # commit. Nothing in the protocol reloads the flashed state.
70
+ def test_restore_is_a_factory_reset
71
+ exsys_usb('on', '1')
72
+ exsys_usb('commit')
73
+ _, err, st = exsys_usb('--yes', 'factory-reset')
74
+ assert_equal 0, st.exitstatus, err
75
+ assert_empty hub.ports_on
76
+ assert_empty hub.flash_ports
77
+ end
78
+
79
+ ## Reading the hub ##################################################
80
+
81
+ def test_status_lists_every_port
82
+ exsys_usb('on', '1', '3')
83
+ out, _, st = exsys_usb('status')
84
+ assert_equal 0, st.exitstatus
85
+ assert_equal 16, out.lines.size
86
+ assert_includes out.lines, "1 on\n"
87
+ assert_includes out.lines, "2 off\n"
88
+ assert_includes out.lines, "3 on\n"
89
+ end
90
+
91
+ def test_status_can_be_asked_about_named_ports
92
+ exsys_usb('on', '3')
93
+ out, _, = exsys_usb('status', '3', '4')
94
+ assert_equal "3 on\n4 off\n", out
95
+ end
96
+
97
+ def test_status_refuses_a_port_the_hub_does_not_have
98
+ _, err, st = exsys_usb('status', '99')
99
+ assert_equal 1, st.exitstatus
100
+ assert_match(/invalid port: 99/, err)
101
+ end
102
+
103
+ def test_query_reports_what_the_hub_says_it_is
104
+ out, _, st = exsys_usb('query')
105
+ assert_equal 0, st.exitstatus
106
+ assert_match(/^id:\s+CENTOS$/, out)
107
+ assert_match(/^ports:\s+16$/, out)
108
+ assert_match(/^firmware:\s+v02$/, out)
109
+ end
110
+
111
+ ## Verbose ##########################################################
112
+
113
+ def test_verbose_reports_the_state_after_a_change
114
+ out, _, = exsys_usb('-v', 'on', '2')
115
+ assert_includes out.lines, "2 on\n"
116
+ assert_equal 16, out.lines.size
117
+ end
118
+
119
+ def test_without_verbose_a_change_says_nothing
120
+ out, _, st = exsys_usb('on', '2')
121
+ assert_equal 0, st.exitstatus
122
+ assert_empty out
123
+ end
124
+
125
+ ## Confirming the irreversible ######################################
126
+
127
+ def test_reset_refuses_without_yes
128
+ _, err, st = exsys_usb('reset')
129
+ assert_equal 1, st.exitstatus
130
+ assert_match(/--yes/, err)
131
+ end
132
+
133
+ def test_reset_runs_when_meant
134
+ _, err, st = exsys_usb('--yes', 'reset')
135
+ assert_equal 0, st.exitstatus, err
136
+ end
137
+
138
+ def test_factory_reset_refuses_without_yes
139
+ exsys_usb('on', '1')
140
+ _, err, st = exsys_usb('factory-reset')
141
+ assert_equal 1, st.exitstatus
142
+ assert_match(/--yes/, err)
143
+ assert_equal [ 1 ], hub.ports_on, 'the hub must not have been touched'
144
+ end
145
+
146
+ def test_factory_reset_runs_when_meant
147
+ exsys_usb('on', '1')
148
+ _, err, st = exsys_usb('--yes', 'factory-reset')
149
+ assert_equal 0, st.exitstatus, err
150
+ assert_empty hub.ports_on
151
+ end
152
+
153
+ ## Exit status -- regression. Every failure used to be printed and
154
+ ## then exited 0, so that `exsys-usb off 3 || alert` never fired.
155
+ ######################################################################
156
+
157
+ def test_a_refused_command_exits_non_zero
158
+ seed_hub(password: 'other')
159
+ _, err, st = exsys_usb('off', '3')
160
+ refute_predicate st, :success?
161
+ assert_equal 1, st.exitstatus
162
+ assert_match(/exsys-usb: 01/, err)
163
+ end
164
+
165
+ def test_a_silent_hub_exits_non_zero
166
+ _, err, st = exsys_usb('off', '3', env: { 'EXSYS_TEST_SILENT' => '1' })
167
+ assert_equal 1, st.exitstatus
168
+ assert_match(/exsys-usb:/, err)
169
+ end
170
+
171
+ def test_a_successful_command_exits_zero
172
+ _, _, st = exsys_usb('on', '1')
173
+ assert_equal 0, st.exitstatus
174
+ end
175
+
176
+ ## Argument handling #################################################
177
+
178
+ # Regression: a port outside 1..16, or a word that to_i turned into
179
+ # 0, used to be accepted and quietly do nothing.
180
+ def test_an_out_of_range_port_is_refused
181
+ [ '0', '17', '99' ].each do |p|
182
+ _, err, st = exsys_usb('on', p)
183
+ assert_equal 1, st.exitstatus, "port #{p} should be refused"
184
+ assert_match(/invalid port: #{p}/, err)
185
+ end
186
+ end
187
+
188
+ def test_a_port_that_is_not_a_number_is_refused
189
+ _, err, st = exsys_usb('off', 'usb3')
190
+ assert_equal 1, st.exitstatus
191
+ assert_match(/invalid port/, err)
192
+ end
193
+
194
+ def test_a_refused_port_leaves_the_hub_alone
195
+ exsys_usb('on', '1')
196
+ exsys_usb('on', '17')
197
+ assert_equal [ 1 ], hub.ports_on
198
+ end
199
+
200
+ # Regression: an action the case did not know fell through it, so
201
+ # the tool exited 0 having done nothing at all.
202
+ def test_an_unknown_action_is_refused
203
+ _, err, st = exsys_usb('onn', '1')
204
+ assert_equal 1, st.exitstatus
205
+ assert_match(/unknown action: onn/, err)
206
+ end
207
+
208
+ def test_a_malformed_set_pair_is_refused
209
+ [ 'foo', '3:maybe', ':on', '3:' ].each do |a|
210
+ _, err, st = exsys_usb('set', a)
211
+ assert_equal 1, st.exitstatus, "#{a} should be refused"
212
+ assert_match(/invalid argument/, err)
213
+ end
214
+ end
215
+
216
+ ## Startup failures -- regression. Option parsing, the debug file
217
+ ## and the hub were all built before the rescue, so their failures
218
+ ## escaped as a Ruby backtrace.
219
+ ######################################################################
220
+
221
+ def test_an_over_long_password_is_reported_not_dumped
222
+ _, err, st = exsys_usb('-p', 'verylongpassword', 'on', '1')
223
+ assert_equal 1, st.exitstatus
224
+ assert_equal "exsys-usb: password too long\n", err
225
+ end
226
+
227
+ def test_an_unopenable_debug_file_is_reported_not_dumped
228
+ _, err, st = exsys_usb('--debug=/nonexistent-dir/x.log', 'on', '1')
229
+ assert_equal 1, st.exitstatus
230
+ assert_match(/\Aexsys-usb: /, err)
231
+ refute_match(/\.rb:\d+:in/, err)
232
+ end
233
+
234
+ def test_a_bad_option_value_is_reported_not_dumped
235
+ _, err, st = exsys_usb('-D', '0', 'set', '3:on')
236
+ assert_equal 1, st.exitstatus
237
+ assert_match(/\Aexsys-usb: /, err)
238
+ refute_match(/\.rb:\d+:in/, err)
239
+ end
240
+
241
+ ## Help ##############################################################
242
+
243
+ def test_no_action_prints_the_usage
244
+ out, _, st = exsys_usb
245
+ assert_equal 0, st.exitstatus
246
+ assert_match(/Usage: exsys-usb ACTION/, out)
247
+ end
248
+
249
+ def test_help_and_version
250
+ out, _, st = exsys_usb('-h')
251
+ assert_equal 0, st.exitstatus
252
+ assert_match(/--device/, out)
253
+
254
+ out, _, st = exsys_usb('-V')
255
+ assert_equal 0, st.exitstatus
256
+ assert_match(/#{ExSYS::VERSION}/, out)
257
+ end
258
+
259
+ ## Debug file ########################################################
260
+
261
+ # Regression: opened RDWR without truncating, so a shorter session
262
+ # left the tail of a longer previous one in place, reading as if the
263
+ # hub had sent it.
264
+ def test_the_debug_file_is_appended_to
265
+ log = File.join(@tmp, 'debug.log')
266
+ File.write(log, "PREVIOUS SESSION#{'.' * 200}\n")
267
+ exsys_usb("--debug=#{log}", 'on', '1')
268
+ assert_match(/\APREVIOUS SESSION/, File.read(log))
269
+ assert_match(/<-- GP/, File.read(log))
270
+ refute_match(/\.{20}\z/, File.read(log).lines.last)
271
+ end
272
+
273
+ def test_two_runs_both_survive_in_the_debug_file
274
+ log = File.join(@tmp, 'debug.log')
275
+ exsys_usb("--debug=#{log}", 'on', '1')
276
+ exsys_usb("--debug=#{log}", 'off', '1')
277
+ assert_equal 2, File.read(log).scan(/<-- GP/).size
278
+ end
279
+
280
+ # Regression: the trace carries the password, in a file that used to
281
+ # be created world-readable.
282
+ def test_the_debug_file_is_private_and_redacted
283
+ log = File.join(@tmp, 'debug.log')
284
+ seed_hub(password: 's3cret')
285
+ exsys_usb('-p', 's3cret', "--debug=#{log}", 'on', '1')
286
+ assert_equal 0o600, File.stat(log).mode & 0o777
287
+ refute_match(/s3cret/, File.read(log))
288
+ assert_match(/<-- SP\*{8}/, File.read(log))
289
+ end
290
+
291
+ ## Concurrency #######################################################
292
+
293
+ # Regression: the GP and the SP were separate openings of the line,
294
+ # so two processes could both read the old state and the second
295
+ # write would drop the first one's port.
296
+ def test_two_concurrent_invocations_do_not_lose_an_update
297
+ env = { 'EXSYS_TEST_LOCK' => File.join(@tmp, 'lock'),
298
+ 'EXSYS_TEST_DELAY' => '0.2' }
299
+
300
+ results = [ '1', '2' ].map do |port|
301
+ Thread.new { exsys_usb('on', port, env: env) }
302
+ end.map(&:value)
303
+
304
+ results.each {|(_, err, st)| assert_equal 0, st.exitstatus, err }
305
+ assert_equal [ 1, 2 ], hub.ports_on
306
+ end
307
+
308
+ # Typing the old name must not silently do nothing, nor quietly do
309
+ # the reset: it must say what the command actually is.
310
+ def test_the_old_restore_action_explains_itself
311
+ _, err, st = exsys_usb('restore')
312
+ assert_equal 1, st.exitstatus
313
+ assert_match(/renamed factory-reset/, err)
314
+ assert_match(/factory defaults/, err)
315
+ assert_equal [], hub.ports_on
316
+ end
317
+
318
+ ## Packaging #########################################################
319
+
320
+ # Regression: the file carried a shebang but no execute bit, so a
321
+ # fresh clone could not run the examples in the README.
322
+ def test_the_executable_is_executable
323
+ assert_predicate File.stat(EXE).mode & 0o111, :positive?
324
+ end
325
+ end