expect-pty 0.3.1 → 0.3.3
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.
- checksums.yaml +4 -4
- data/.rubocop.yml +41 -4
- data/CHANGELOG.md +14 -0
- data/CONTRIBUTING.md +23 -0
- data/Gemfile +2 -1
- data/README.md +23 -6
- data/benchmark/matching.rb +72 -0
- data/benchmark/relay.rb +48 -0
- data/benchmark/send_slow.rb +41 -0
- data/benchmark/support.rb +94 -0
- data/docs/COMPATIBILITY.md +1 -1
- data/docs/INTERNAL_CONTRACTS.md +65 -0
- data/docs/PERFORMANCE.md +60 -0
- data/docs/RELEASING.md +8 -6
- data/docs/VERIFICATION.md +24 -0
- data/examples/kibitz/kibitz.rb +2 -0
- data/expect-pty.gemspec +2 -1
- data/lib/expect/interaction.rb +13 -6
- data/lib/expect/logging.rb +96 -0
- data/lib/expect/matcher.rb +26 -5
- data/lib/expect/pattern.rb +9 -5
- data/lib/expect/relay.rb +3 -1
- data/lib/expect/relay_writer.rb +2 -0
- data/lib/expect/session_resources.rb +29 -11
- data/lib/expect/terminal.rb +35 -0
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +85 -145
- data/script/ci +5 -0
- data/script/release.rb +37 -5
- data/test/cleanup_test.rb +251 -0
- data/test/configuration_test.rb +43 -0
- data/test/io_test.rb +35 -0
- data/test/pattern_offset_test.rb +43 -0
- data/test/process_test.rb +46 -0
- data/test/relay_recovery_test.rb +37 -0
- data/test/release_test.rb +94 -16
- data/test/scan_reuse_test.rb +109 -0
- data/test/timeout_test.rb +119 -0
- metadata +15 -3
data/lib/expect.rb
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
require "pty"
|
|
4
4
|
require "io/console"
|
|
5
5
|
require "io/wait"
|
|
6
|
-
require "shellwords"
|
|
7
6
|
require "stringio"
|
|
8
7
|
require "forwardable"
|
|
9
8
|
require_relative "expect/version"
|
|
@@ -21,6 +20,8 @@ class Expect
|
|
|
21
20
|
CONTINUE = :continue
|
|
22
21
|
CONTINUE_WITHOUT_RESET = :continue_without_reset
|
|
23
22
|
READ_SIZE = 16_384
|
|
23
|
+
CONFIGURATION_MUTEX = Mutex.new
|
|
24
|
+
private_constant :CONFIGURATION_MUTEX
|
|
24
25
|
|
|
25
26
|
class SpawnError < StandardError; end
|
|
26
27
|
|
|
@@ -40,25 +41,39 @@ class Expect
|
|
|
40
41
|
return @configuration if defined?(@configuration)
|
|
41
42
|
return superclass.configuration unless self == Expect
|
|
42
43
|
|
|
43
|
-
@configuration
|
|
44
|
+
CONFIGURATION_MUTEX.synchronize { @configuration ||= Configuration.new.freeze }
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
# 基于旧快照构造可修改副本,全部赋值与配置块成功后才发布,异常时保留原配置。
|
|
47
48
|
def configure(**)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
raise ThreadError, "nested configure is not supported" if CONFIGURATION_MUTEX.owned?
|
|
50
|
+
|
|
51
|
+
# 初始化默认快照后,将整个读改写过程串行化,避免并发配置丢失更新。
|
|
52
|
+
configuration
|
|
53
|
+
CONFIGURATION_MUTEX.synchronize do
|
|
54
|
+
updated = Configuration.new(**configuration.to_h, **)
|
|
55
|
+
yield updated if block_given?
|
|
56
|
+
@configuration = updated.freeze
|
|
57
|
+
end
|
|
51
58
|
end
|
|
52
59
|
|
|
53
60
|
# 创建并启动会话;有块时返回块结果并确保关闭,无块时由调用方负责生命周期。
|
|
54
61
|
def spawn(*command, env: {}, chdir: nil, **)
|
|
55
62
|
session = new(**)
|
|
56
63
|
session.spawn(*command, env: env, chdir: chdir)
|
|
64
|
+
spawned = true
|
|
57
65
|
return session unless block_given?
|
|
58
66
|
|
|
59
67
|
yield session
|
|
68
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 记录本次作用域的失败,清理后原样传播,包括非 StandardError 异常。
|
|
69
|
+
failed = true
|
|
70
|
+
raise
|
|
60
71
|
ensure
|
|
61
|
-
session
|
|
72
|
+
if session && (block_given? || !spawned)
|
|
73
|
+
session.__send__(:cleanup_preserving_failure, failed) do
|
|
74
|
+
session.close(graceful: spawned && session.graceful_close?)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
62
77
|
end
|
|
63
78
|
|
|
64
79
|
# 适配已有 IO;own: true 接管关闭责任,初始化失败也释放接管的读写端。
|
|
@@ -69,11 +84,14 @@ class Expect
|
|
|
69
84
|
return session unless block_given?
|
|
70
85
|
|
|
71
86
|
yield session
|
|
87
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 初始化和块异常均须保留,清理失败不能替换原始原因。
|
|
88
|
+
failed = true
|
|
89
|
+
raise
|
|
72
90
|
ensure
|
|
73
|
-
if initialized
|
|
74
|
-
session.
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
if session && (block_given? || !initialized)
|
|
92
|
+
session.__send__(:cleanup_preserving_failure, failed) do
|
|
93
|
+
session.close(graceful: initialized && session.graceful_close?)
|
|
94
|
+
end
|
|
77
95
|
end
|
|
78
96
|
end
|
|
79
97
|
|
|
@@ -165,14 +183,15 @@ class Expect
|
|
|
165
183
|
@tty_name = slave.path
|
|
166
184
|
spawn(*command, env: env, chdir: chdir) unless command.empty?
|
|
167
185
|
initialized = true
|
|
186
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 构造异常时也要关闭已创建的资源并回收已启动的子进程。
|
|
187
|
+
failed = true
|
|
188
|
+
raise
|
|
168
189
|
ensure
|
|
169
|
-
|
|
170
|
-
master&.close unless master&.closed?
|
|
171
|
-
slave&.close unless slave&.closed?
|
|
172
|
-
end
|
|
190
|
+
cleanup_preserving_failure(failed) { close(graceful: false) } if @resources && !initialized
|
|
173
191
|
end
|
|
174
192
|
|
|
175
193
|
# 在新控制终端中执行命令并同步确认 exec 结果;同一会话只能启动一次。
|
|
194
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- PTY 子进程启动与失败回传共用一次生命周期。
|
|
176
195
|
def spawn(*command, env: {}, chdir: nil)
|
|
177
196
|
raise SpawnError, "cannot reuse a spawned session" if @command
|
|
178
197
|
raise SpawnError, "only a new PTY session can spawn" unless @slave && !@slave.closed? && !closed?
|
|
@@ -223,6 +242,7 @@ class Expect
|
|
|
223
242
|
from_child&.close unless from_child&.closed?
|
|
224
243
|
to_parent&.close unless to_parent&.closed?
|
|
225
244
|
end
|
|
245
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
226
246
|
|
|
227
247
|
# 在当前会话等待文本或事件,返回模式序号或 nil。
|
|
228
248
|
def expect(...) = expect_result(...).number
|
|
@@ -297,6 +317,7 @@ class Expect
|
|
|
297
317
|
end
|
|
298
318
|
|
|
299
319
|
# 按 Ruby to_s 规则原样写入所有字节,返回字节数;背压等待受 write_timeout 限制。
|
|
320
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- 写入、背压排空和同一期限必须同步推进。
|
|
300
321
|
def write(*objects)
|
|
301
322
|
raise IOError, "closed Expect session" if closed? || writer.closed?
|
|
302
323
|
|
|
@@ -339,6 +360,7 @@ class Expect
|
|
|
339
360
|
end
|
|
340
361
|
data.bytesize
|
|
341
362
|
end
|
|
363
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
342
364
|
|
|
343
365
|
# 链式写入单个对象,返回当前会话。
|
|
344
366
|
def <<(object)
|
|
@@ -364,88 +386,12 @@ class Expect
|
|
|
364
386
|
object.to_s.each_char do |character|
|
|
365
387
|
sleep(pause) if pause.positive?
|
|
366
388
|
count += write(character)
|
|
367
|
-
read_available if !eof? && to_io.wait_readable(0
|
|
389
|
+
read_available if !eof? && to_io.wait_readable(0)
|
|
368
390
|
end
|
|
369
391
|
end
|
|
370
392
|
count
|
|
371
393
|
end
|
|
372
394
|
|
|
373
|
-
# 读取当前日志目标,可能为库打开的文件、借用的 IO、回调或 nil。
|
|
374
|
-
attr_reader :log_output
|
|
375
|
-
|
|
376
|
-
# 替换借用的日志目标或停止日志;先校验新目标,失败时保留旧目标。
|
|
377
|
-
def log_output=(target)
|
|
378
|
-
unless target.nil? || target.respond_to?(:write) || target.respond_to?(:call)
|
|
379
|
-
raise ArgumentError, "log output must support write or call, or be nil"
|
|
380
|
-
end
|
|
381
|
-
|
|
382
|
-
replace_log(target)
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
# 打开追加/覆盖日志文件,或注册接收字节的日志块;同一次只能指定一种目标。
|
|
386
|
-
def log_to(target = nil, mode: "a", &block)
|
|
387
|
-
raise ArgumentError, "provide a log target or a block, not both" if block && target
|
|
388
|
-
|
|
389
|
-
target = block if block
|
|
390
|
-
if target.respond_to?(:to_path) || target.is_a?(String)
|
|
391
|
-
raise ArgumentError, "log mode must be a or w" unless %w[a w].include?(mode)
|
|
392
|
-
|
|
393
|
-
# 库打开的文件由 SessionResources 持有,替换日志或关闭会话时释放;外部 IO 只借用。
|
|
394
|
-
replace_log(File.open(target, "#{mode}b", 0o600), owned: true)
|
|
395
|
-
else
|
|
396
|
-
raise ArgumentError, "provide a log target or a block" unless target
|
|
397
|
-
|
|
398
|
-
self.log_output = target
|
|
399
|
-
end
|
|
400
|
-
end
|
|
401
|
-
|
|
402
|
-
# 向当前日志目标补写内容,支持 IO 和回调,不发送给子进程或监听器。
|
|
403
|
-
def write_log(*objects)
|
|
404
|
-
target = log_output
|
|
405
|
-
return unless target
|
|
406
|
-
|
|
407
|
-
data = objects.map { |object| object.to_s.b }.join
|
|
408
|
-
target.respond_to?(:call) ? target.call(data) : emit(target, data)
|
|
409
|
-
end
|
|
410
|
-
|
|
411
|
-
# 返回监听器列表副本,避免外部原地修改转发关系。
|
|
412
|
-
def listeners = @listeners.dup
|
|
413
|
-
|
|
414
|
-
# 校验所有监听器均可写后一次性替换列表,外部数组后续修改不会影响会话。
|
|
415
|
-
def listeners=(outputs)
|
|
416
|
-
outputs = Array(outputs)
|
|
417
|
-
raise ArgumentError, "listeners must support write" unless outputs.all? { |output| output.respond_to?(:write) }
|
|
418
|
-
|
|
419
|
-
@listeners = outputs.dup
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
# 查询可恢复的终端模式字符串,或通过系统 stty 设置模式;参数按数组传递,不经 shell。
|
|
423
|
-
def stty(*modes)
|
|
424
|
-
return "" unless tty?
|
|
425
|
-
|
|
426
|
-
modes = modes.flat_map { |mode| Shellwords.split(mode.to_s) }
|
|
427
|
-
modes = ["-g"] if modes.empty?
|
|
428
|
-
reader, sink = IO.pipe
|
|
429
|
-
child = Process.spawn("stty", *modes, in: to_io, out: sink, err: sink)
|
|
430
|
-
sink.close
|
|
431
|
-
output = reader.read
|
|
432
|
-
_, status = Process.waitpid2(child)
|
|
433
|
-
raise IOError, "stty failed: #{output.strip}" unless status.success?
|
|
434
|
-
|
|
435
|
-
output.strip
|
|
436
|
-
ensure
|
|
437
|
-
reader&.close unless reader&.closed?
|
|
438
|
-
sink&.close unless sink&.closed?
|
|
439
|
-
end
|
|
440
|
-
|
|
441
|
-
# 读取终端的 [行数, 列数]。
|
|
442
|
-
def winsize = to_io.winsize
|
|
443
|
-
|
|
444
|
-
# 更新终端尺寸,由内核通知前台进程。
|
|
445
|
-
def winsize=(size)
|
|
446
|
-
to_io.winsize = size
|
|
447
|
-
end
|
|
448
|
-
|
|
449
395
|
# 轮询回收状态直到进程退出或期限到达;返回 Process::Status 或 nil,超时不丢弃 PID。
|
|
450
396
|
def wait(timeout: nil)
|
|
451
397
|
period = Expect.duration(timeout)
|
|
@@ -489,43 +435,75 @@ class Expect
|
|
|
489
435
|
def close(graceful: graceful_close?)
|
|
490
436
|
soft_close if graceful
|
|
491
437
|
nil
|
|
438
|
+
rescue Exception # rubocop:disable Lint/RescueException -- 软关闭的原始异常在硬关闭兜底后继续传播。
|
|
439
|
+
failed = true
|
|
440
|
+
raise
|
|
492
441
|
ensure
|
|
493
|
-
hard_close
|
|
442
|
+
cleanup_preserving_failure(failed) { hard_close }
|
|
494
443
|
end
|
|
495
444
|
|
|
496
445
|
private
|
|
497
446
|
|
|
447
|
+
# 仅在本次生命周期已有异常时抑制常规清理错误;调用者 rescue 中的旧异常不算本次失败。
|
|
448
|
+
def cleanup_preserving_failure(failed)
|
|
449
|
+
yield
|
|
450
|
+
rescue IOError, SystemCallError
|
|
451
|
+
raise unless failed
|
|
452
|
+
end
|
|
453
|
+
|
|
498
454
|
# 共用的进程关闭流程;force 控制是否允许 KILL,只有资源创建者能够操作直属子进程。
|
|
499
455
|
def finish_close(timeout:, term_timeout:, force:)
|
|
456
|
+
failure = nil
|
|
457
|
+
# 预期的清理错误延后传播,保证其余所属资源和直属子进程仍能完成清理。
|
|
458
|
+
cleanup = lambda do |&step|
|
|
459
|
+
step.call
|
|
460
|
+
rescue IOError, SystemCallError => error
|
|
461
|
+
failure ||= error
|
|
462
|
+
nil
|
|
463
|
+
end
|
|
500
464
|
# IO 关闭与进程退出独立记录:软关闭可能已经 closed?,但仍保留活跃 PID。
|
|
501
|
-
@resources.close_handles
|
|
465
|
+
cleanup.call { @resources.close_handles }
|
|
502
466
|
@closed = true
|
|
503
|
-
@interact_inputs&.
|
|
504
|
-
|
|
467
|
+
@interact_inputs&.delete_if do |_io, input|
|
|
468
|
+
cleanup.call do
|
|
469
|
+
input.close(graceful: false)
|
|
470
|
+
true
|
|
471
|
+
end
|
|
472
|
+
end
|
|
505
473
|
@interact_output = nil
|
|
506
474
|
@relay_outputs&.clear
|
|
507
475
|
@relay_history&.clear
|
|
508
476
|
@relay_callback = nil
|
|
477
|
+
status = finish_child_close(timeout: timeout, term_timeout: term_timeout, force: force)
|
|
478
|
+
completed = true
|
|
479
|
+
status
|
|
480
|
+
ensure
|
|
481
|
+
cleanup.call { self.log_output = nil }
|
|
482
|
+
# 用本次流程的完成状态判断异常传播,不能误把调用者 rescue 中的异常当成当前错误。
|
|
483
|
+
raise failure if failure && completed
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# 句柄清理失败不改变进程策略;未回收 PID 保留给重复关闭或终结器继续处理。
|
|
487
|
+
def finish_child_close(timeout:, term_timeout:, force:)
|
|
509
488
|
return process_status unless @resources.owner == Process.pid && pid
|
|
510
489
|
return process_status if wait(timeout: timeout)
|
|
511
490
|
|
|
512
491
|
signal_child("TERM")
|
|
513
492
|
return process_status if wait(timeout: term_timeout)
|
|
514
493
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
ensure
|
|
520
|
-
self.log_output = nil
|
|
494
|
+
return unless force
|
|
495
|
+
|
|
496
|
+
signal_child("KILL")
|
|
497
|
+
wait(timeout: 1)
|
|
521
498
|
end
|
|
522
499
|
|
|
523
500
|
# 统一初始化 PTY 与已有 IO 会话,复制配置并注册不直接捕获会话的资源终结器。
|
|
524
501
|
def initialize_session(reader, writer:, slave: nil, own: false, **)
|
|
502
|
+
# 先登记所有权,后续校验失败也使用同一个资源对象逐个清理所属 IO。
|
|
503
|
+
@resources = SessionResources.new(reader, writer: writer, slave: slave, own: own)
|
|
525
504
|
raise ArgumentError, "reader must be a real IO" unless reader.is_a?(IO) && !reader.closed?
|
|
526
505
|
raise ArgumentError, "writer must be a real IO" unless writer.is_a?(IO) && !writer.closed?
|
|
527
506
|
|
|
528
|
-
@resources = SessionResources.new(reader, writer: writer, slave: slave, own: own)
|
|
529
507
|
@pty = reader.tty?
|
|
530
508
|
@slave = slave
|
|
531
509
|
@configuration = Configuration.new(**self.class.configuration.to_h, **)
|
|
@@ -568,13 +546,14 @@ class Expect
|
|
|
568
546
|
end
|
|
569
547
|
|
|
570
548
|
# 先将读取字节交给匹配或转接缓冲,再记录日志;日志失败也能恢复输入。
|
|
571
|
-
def read_available(propagate: true, buffer: @buffer)
|
|
549
|
+
def read_available(propagate: true, buffer: @buffer, trim: true)
|
|
572
550
|
return nil if eof?
|
|
573
551
|
|
|
574
552
|
# 写入背压也会读取;转接期间统一交给转义处理器,不能直接转发或另存匹配缓冲。
|
|
575
553
|
if @interaction_buffer
|
|
576
554
|
buffer = @interaction_buffer
|
|
577
555
|
propagate = false
|
|
556
|
+
trim = false
|
|
578
557
|
end
|
|
579
558
|
|
|
580
559
|
begin
|
|
@@ -597,7 +576,7 @@ class Expect
|
|
|
597
576
|
end
|
|
598
577
|
data = data.b
|
|
599
578
|
buffer << data
|
|
600
|
-
trim_buffer if
|
|
579
|
+
trim_buffer if trim
|
|
601
580
|
trace("received #{data.inspect}", level: 2) if debug_level >= 2
|
|
602
581
|
trace("buffer #{@buffer.inspect}", level: 3) if debug_level >= 3
|
|
603
582
|
# 仅在真实读取时记录日志,后续匹配或人工转接重用缓冲时不会重复记录。
|
|
@@ -612,47 +591,6 @@ class Expect
|
|
|
612
591
|
@buffer = @buffer.byteslice(-limit, limit) if limit&.positive? && @buffer.bytesize > limit
|
|
613
592
|
end
|
|
614
593
|
|
|
615
|
-
# 按各自开关将接收字节转发到 stdout 和监听器,不重复写日志。
|
|
616
|
-
def propagate(data)
|
|
617
|
-
emit($stdout, data) if log_stdout?
|
|
618
|
-
@listeners.each { |listener| emit(listener, data) } if log_listeners?
|
|
619
|
-
end
|
|
620
|
-
|
|
621
|
-
# 向目标写入并在支持时立即 flush,使日志和终端输出及时可见。
|
|
622
|
-
def emit(target, data)
|
|
623
|
-
offset = 0
|
|
624
|
-
while offset < data.bytesize
|
|
625
|
-
count = target.write(data.byteslice(offset..))
|
|
626
|
-
unless count.is_a?(Integer) && count.positive? && count <= data.bytesize - offset
|
|
627
|
-
raise IOError, "write must return the number of accepted bytes"
|
|
628
|
-
end
|
|
629
|
-
|
|
630
|
-
offset += count
|
|
631
|
-
end
|
|
632
|
-
target.flush if target.respond_to?(:flush)
|
|
633
|
-
end
|
|
634
|
-
|
|
635
|
-
# 按诊断级别向 stderr 输出会话标识和消息。
|
|
636
|
-
def trace(message, level: 1)
|
|
637
|
-
warn("#{inspect}: #{message}") if debug_level >= level
|
|
638
|
-
end
|
|
639
|
-
|
|
640
|
-
# 交接日志目标和所有权,只关闭库拥有的旧文件;失败时释放新打开的文件。
|
|
641
|
-
def replace_log(target, owned: false)
|
|
642
|
-
previous = log_output
|
|
643
|
-
# 重复赋值同一目标时保留原所有权,防止将库打开的文件误变成借用资源。
|
|
644
|
-
return target if previous.equal?(target)
|
|
645
|
-
|
|
646
|
-
previous.close if @resources.owned_log && previous && !previous.closed?
|
|
647
|
-
# 终结器只持有所属文件;借用回调可能捕获会话,不能让它经资源对象成为 GC 根。
|
|
648
|
-
@resources.owned_log = owned ? target : nil
|
|
649
|
-
@log_output = target
|
|
650
|
-
target
|
|
651
|
-
rescue Exception # rubocop:disable Lint/RescueException -- 替换失败时仍释放刚打开的文件。
|
|
652
|
-
target.close if owned && target && !target.closed?
|
|
653
|
-
raise
|
|
654
|
-
end
|
|
655
|
-
|
|
656
594
|
# 仅由资源创建者向仍未回收的子进程发送信号;若进程刚好退出,则尝试回收。
|
|
657
595
|
def signal_child(signal)
|
|
658
596
|
return unless alive? && @resources.owner == Process.pid
|
|
@@ -663,4 +601,6 @@ class Expect
|
|
|
663
601
|
end
|
|
664
602
|
end
|
|
665
603
|
|
|
604
|
+
require_relative "expect/logging"
|
|
605
|
+
require_relative "expect/terminal"
|
|
666
606
|
require_relative "expect/interaction"
|
data/script/ci
CHANGED
|
@@ -6,6 +6,11 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
|
|
6
6
|
bundle exec rake
|
|
7
7
|
bundle exec ruby examples/dialogue.rb
|
|
8
8
|
|
|
9
|
+
# 只检查小规模工作负载的正确性,不为共享 CI 机器设置性能阈值。
|
|
10
|
+
for benchmark in matching relay send_slow; do
|
|
11
|
+
bundle exec ruby "benchmark/${benchmark}.rb" --smoke
|
|
12
|
+
done
|
|
13
|
+
|
|
9
14
|
mkdir -p tmp/ci
|
|
10
15
|
gem_version="$(ruby -Ilib -rexpect/version -e 'print Expect::VERSION')"
|
|
11
16
|
gem_file="tmp/ci/expect-pty-${gem_version}.gem"
|
data/script/release.rb
CHANGED
|
@@ -83,11 +83,16 @@ class Release
|
|
|
83
83
|
|
|
84
84
|
private
|
|
85
85
|
|
|
86
|
-
def capture(*
|
|
87
|
-
|
|
86
|
+
def capture(*)
|
|
87
|
+
capture_bytes(*).strip
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Git blob 是任意字节,不能沿用命令文本的 strip,尾部换行和 NUL 也属于发布内容。
|
|
91
|
+
def capture_bytes(*arguments)
|
|
92
|
+
output, error, status = Open3.capture3(*arguments, binmode: true)
|
|
88
93
|
raise "#{arguments.first} failed: #{error.strip}" unless status.success?
|
|
89
94
|
|
|
90
|
-
output
|
|
95
|
+
output
|
|
91
96
|
end
|
|
92
97
|
|
|
93
98
|
def command(*arguments)
|
|
@@ -124,7 +129,7 @@ class Release
|
|
|
124
129
|
def verify_package
|
|
125
130
|
package = Gem::Package.new(@artifact)
|
|
126
131
|
expected = Gem::Specification.load(File.expand_path("expect-pty.gemspec"))
|
|
127
|
-
fields = %i[name version platform summary description authors licenses required_ruby_version
|
|
132
|
+
fields = %i[name version platform summary description authors licenses homepage required_ruby_version
|
|
128
133
|
required_rubygems_version require_paths metadata dependencies extensions executables bindir
|
|
129
134
|
post_install_message]
|
|
130
135
|
unless fields.all? { |field| package.spec.public_send(field) == expected.public_send(field) }
|
|
@@ -132,6 +137,7 @@ class Release
|
|
|
132
137
|
end
|
|
133
138
|
raise "Artifact file list differs from the source" unless package.contents.sort == expected.files.sort
|
|
134
139
|
|
|
140
|
+
committed = committed_files if @commit
|
|
135
141
|
# 直接检查归档中的权限,避免解包时本机 umask 改写执行位。
|
|
136
142
|
File.open(@artifact, "rb") do |io|
|
|
137
143
|
Gem::Package::TarReader.new(io) do |archive|
|
|
@@ -139,7 +145,10 @@ class Release
|
|
|
139
145
|
package.open_tar_gz(data) do |tar|
|
|
140
146
|
tar.each do |entry|
|
|
141
147
|
file = entry.full_name
|
|
142
|
-
|
|
148
|
+
bytes = entry.read
|
|
149
|
+
raise "Artifact differs from source: #{file}" unless entry.file? && bytes == File.binread(file)
|
|
150
|
+
|
|
151
|
+
verify_committed_file(file, bytes, entry.header.mode, committed) if committed
|
|
143
152
|
next if entry.header.mode & 0o111 == File.stat(file).mode & 0o111
|
|
144
153
|
|
|
145
154
|
raise "Artifact executable permissions differ: #{file}"
|
|
@@ -149,6 +158,29 @@ class Release
|
|
|
149
158
|
end
|
|
150
159
|
end
|
|
151
160
|
|
|
161
|
+
# 工作区干净不代表所有打包文件都来自提交:ignore、assume-unchanged 和 filemode 均可隐藏差异。
|
|
162
|
+
# 只在正式发布时核对提交树;dry-run 仍允许检验尚未提交的开发源码。
|
|
163
|
+
def committed_files
|
|
164
|
+
capture_bytes("git", "ls-tree", "-rz", "--full-tree", @commit).split("\0").to_h do |record|
|
|
165
|
+
metadata, file = record.split("\t", 2)
|
|
166
|
+
mode, type, object = metadata.split
|
|
167
|
+
[file, [mode.to_i(8), type, object]]
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def verify_committed_file(file, bytes, mode, committed)
|
|
172
|
+
committed_mode, type, object = committed[file.b]
|
|
173
|
+
unless type == "blob" && committed_mode & 0o170000 == 0o100000
|
|
174
|
+
raise "Artifact file is not in release commit: #{file}"
|
|
175
|
+
end
|
|
176
|
+
unless bytes == capture_bytes("git", "cat-file", "blob", object)
|
|
177
|
+
raise "Artifact differs from release commit: #{file}"
|
|
178
|
+
end
|
|
179
|
+
return if mode & 0o111 == committed_mode & 0o111
|
|
180
|
+
|
|
181
|
+
raise "Artifact executable permissions differ from release commit: #{file}"
|
|
182
|
+
end
|
|
183
|
+
|
|
152
184
|
def get(path)
|
|
153
185
|
uri = URI("#{GEM_HOST}#{path}")
|
|
154
186
|
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) do |http|
|