expect-pty 0.5.3 → 0.6.1
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/CHANGELOG.md +28 -0
- data/README.md +28 -19
- data/docs/API.md +88 -0
- data/docs/MIGRATION.md +15 -0
- data/lib/expect/cleanup.rb +33 -0
- data/lib/expect/configuration.rb +87 -66
- data/lib/expect/interaction.rb +226 -209
- data/lib/expect/logging.rb +157 -156
- data/lib/expect/matcher.rb +30 -29
- data/lib/expect/pattern.rb +8 -2
- data/lib/expect/pattern_list.rb +35 -5
- data/lib/expect/redactor.rb +11 -4
- data/lib/expect/relay.rb +97 -73
- data/lib/expect/relay_writer.rb +2 -1
- data/lib/expect/result.rb +32 -8
- data/lib/expect/session.rb +508 -0
- data/lib/expect/session_resources.rb +10 -2
- data/lib/expect/terminal.rb +78 -78
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +333 -520
- data/sig/expect.rbs +355 -0
- metadata +13 -106
- data/.rubocop.yml +0 -66
- data/CONTRIBUTING.md +0 -29
- data/Gemfile +0 -28
- data/Rakefile +0 -38
- data/benchmark/matching.rb +0 -98
- data/benchmark/redactor.rb +0 -64
- data/benchmark/relay.rb +0 -48
- data/benchmark/scaling.rb +0 -70
- data/benchmark/send_slow.rb +0 -41
- data/benchmark/support.rb +0 -114
- data/docs/COMPATIBILITY.md +0 -98
- data/docs/INTERNAL_CONTRACTS.md +0 -154
- data/docs/PERFORMANCE.md +0 -165
- data/docs/RELEASING.md +0 -84
- data/docs/VERIFICATION.md +0 -456
- data/examples/dialogue.rb +0 -30
- data/examples/kibitz/README.md +0 -81
- data/examples/kibitz/kibitz.rb +0 -142
- data/examples/kibitz/test_kibitz.rb +0 -37
- data/examples/ssh_auto.rb +0 -94
- data/examples/ssh_interact.rb +0 -159
- data/examples/ssh_login.rb +0 -64
- data/expect-pty.gemspec +0 -33
- data/script/ci +0 -121
- data/script/release.rb +0 -319
- data/test/buffer_accounting_test.rb +0 -83
- data/test/cleanup_test.rb +0 -251
- data/test/compare_upstream.rb +0 -157
- data/test/configuration_test.rb +0 -133
- data/test/deadline_test.rb +0 -237
- data/test/diagnostics_test.rb +0 -353
- data/test/edge_case_test.rb +0 -262
- data/test/fixtures/ssh_scripts/01_identity.sh +0 -4
- data/test/fixtures/ssh_scripts/02_output.sh +0 -5
- data/test/fixtures/ssh_scripts/03_delayed.sh +0 -6
- data/test/fixtures/ssh_scripts/04_failure.sh +0 -2
- data/test/fixtures/ssh_scripts/05_recovery.sh +0 -3
- data/test/initialization_failure_test.rb +0 -92
- data/test/integration/README.md +0 -109
- data/test/integration/ssh_scripts.rb +0 -94
- data/test/interact_test.rb +0 -253
- data/test/interconnect_test.rb +0 -425
- data/test/io_test.rb +0 -321
- data/test/kibitz_test.rb +0 -45
- data/test/lifecycle_contract_test.rb +0 -71
- data/test/literal_scan_test.rb +0 -97
- data/test/matching_test.rb +0 -211
- data/test/multi_session_test.rb +0 -66
- data/test/ownership_sequence_test.rb +0 -208
- data/test/pattern_offset_test.rb +0 -43
- data/test/process_interruption_test.rb +0 -296
- data/test/process_test.rb +0 -291
- data/test/redactor_test.rb +0 -183
- data/test/relay_recovery_test.rb +0 -451
- data/test/relay_reentrancy_test.rb +0 -159
- data/test/release_test.rb +0 -375
- data/test/ruby_api_test.rb +0 -515
- data/test/scan_reuse_test.rb +0 -109
- data/test/script_logging_test.rb +0 -124
- data/test/support/interact_probe.rb +0 -125
- data/test/support/kibitz_probe.rb +0 -177
- data/test/support/script_probe.rb +0 -158
- data/test/terminal_cleanup_test.rb +0 -349
- data/test/test_helper.rb +0 -58
- data/test/timeout_test.rb +0 -289
- data/test/write_contract_test.rb +0 -105
data/examples/kibitz/kibitz.rb
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "socket"
|
|
4
|
-
require "tmpdir"
|
|
5
|
-
require "optparse"
|
|
6
|
-
require "shellwords"
|
|
7
|
-
require_relative "../../lib/expect/pty"
|
|
8
|
-
|
|
9
|
-
# Local counterpart of jacoby/expect.pm examples/kibitz: both keyboards feed
|
|
10
|
-
# one process, whose output is broadcast to both terminals using listeners.
|
|
11
|
-
module Kibitz
|
|
12
|
-
ESCAPE = "\x1d".b.freeze
|
|
13
|
-
|
|
14
|
-
def self.relay(input:, output:, peer:, shell: nil, escape: ESCAPE, timeout: nil, log: nil)
|
|
15
|
-
Expect.open(input, log_stdout: false, write_timeout: 5) do |keyboard|
|
|
16
|
-
Expect.open(peer, log_stdout: false, write_timeout: 5) do |partner|
|
|
17
|
-
keyboard.on_sequence(escape) if escape
|
|
18
|
-
if shell
|
|
19
|
-
keyboard.listeners = [shell]
|
|
20
|
-
partner.listeners = [shell]
|
|
21
|
-
shell.listeners = [output, partner]
|
|
22
|
-
shell.log_stdout = false
|
|
23
|
-
shell.log_output = log if log
|
|
24
|
-
# Keep canonical input, echo and ISIG on the process's own terminal.
|
|
25
|
-
shell.raw_terminal = false
|
|
26
|
-
else
|
|
27
|
-
keyboard.listeners = [partner]
|
|
28
|
-
partner.listeners = [output]
|
|
29
|
-
partner.log_output = log if log
|
|
30
|
-
end
|
|
31
|
-
stopped = Expect.interconnect(*[keyboard, partner, shell].compact, timeout: timeout)
|
|
32
|
-
reason = if stopped.nil?
|
|
33
|
-
:timeout
|
|
34
|
-
elsif stopped.equal?(keyboard)
|
|
35
|
-
:local
|
|
36
|
-
elsif stopped.equal?(partner)
|
|
37
|
-
:peer
|
|
38
|
-
else
|
|
39
|
-
:process
|
|
40
|
-
end
|
|
41
|
-
{ reason: reason, tail: keyboard.buffer }
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def self.escape(value)
|
|
47
|
-
return (value.getbyte(1) & 31).chr if value.match?(/\A\^[@A-Z\[\\\]\^_]\z/)
|
|
48
|
-
|
|
49
|
-
raise OptionParser::InvalidArgument, "escape must not be empty" if value.empty?
|
|
50
|
-
|
|
51
|
-
value
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- CLI 建连、终端接管和 ensure 清理共享一次生命周期。
|
|
55
|
-
def self.run(argv, input: $stdin, output: $stdout)
|
|
56
|
-
options = { escape: ESCAPE }
|
|
57
|
-
parser = OptionParser.new do |opts|
|
|
58
|
-
opts.banner = "Usage: ruby examples/kibitz/kibitz.rb [options] [-- program args...]"
|
|
59
|
-
opts.on("--join SOCKET", "Join the local session printed by its host") { |value| options[:join] = value }
|
|
60
|
-
opts.on("--noproc", "Exchange input directly, with no shared process or local echo") { options[:noproc] = true }
|
|
61
|
-
opts.on("--escape TEXT", "Local exit sequence (default ^]; caret notation accepted)") do |value|
|
|
62
|
-
options[:escape] = escape(value)
|
|
63
|
-
end
|
|
64
|
-
opts.on("--noescape", "Forward all keys; end by exiting the shared process") { options[:escape] = nil }
|
|
65
|
-
opts.on("--timeout SECONDS", Float, "Limit connection wait and then relay duration") do |value|
|
|
66
|
-
options[:timeout] = value
|
|
67
|
-
end
|
|
68
|
-
opts.on("--log FILE", "Create a new received-output log (0600)") { |value| options[:log] = value }
|
|
69
|
-
opts.on("-h", "--help") do
|
|
70
|
-
output.puts(opts)
|
|
71
|
-
return 0
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
parser.order!(argv)
|
|
75
|
-
raise OptionParser::InvalidArgument, "a terminal is required" unless input.tty?
|
|
76
|
-
if options[:timeout] && (!options[:timeout].finite? || options[:timeout] <= 0)
|
|
77
|
-
raise OptionParser::InvalidArgument,
|
|
78
|
-
"timeout must be positive and finite"
|
|
79
|
-
end
|
|
80
|
-
if options[:join] && (options[:noproc] || !argv.empty?)
|
|
81
|
-
raise OptionParser::InvalidArgument,
|
|
82
|
-
"--join cannot start a program or use --noproc"
|
|
83
|
-
end
|
|
84
|
-
raise OptionParser::InvalidArgument, "--noproc cannot start a program" if options[:noproc] && !argv.empty?
|
|
85
|
-
|
|
86
|
-
output.sync = true
|
|
87
|
-
# The ensure below closes the log after both terminal connections end.
|
|
88
|
-
log = File.open(options[:log], File::WRONLY | File::CREAT | File::EXCL, 0o600) if options[:log] # rubocop:disable Style/FileOpen
|
|
89
|
-
|
|
90
|
-
if options[:join]
|
|
91
|
-
peer = UNIXSocket.new(options[:join])
|
|
92
|
-
else
|
|
93
|
-
# A short, private socket path works with macOS's sockaddr_un limit.
|
|
94
|
-
directory = Dir.mktmpdir("expect-kibitz-", "/tmp")
|
|
95
|
-
socket_path = File.join(directory, "peer.sock")
|
|
96
|
-
server = UNIXServer.new(socket_path)
|
|
97
|
-
File.chmod(0o600, socket_path)
|
|
98
|
-
output.puts("Join from another terminal on this machine:")
|
|
99
|
-
output.puts(Shellwords.join([RbConfig.ruby, File.expand_path(__FILE__), "--join", socket_path]))
|
|
100
|
-
raise IOError, "timed out waiting for partner" unless server.wait_readable(options[:timeout])
|
|
101
|
-
|
|
102
|
-
peer = server.accept
|
|
103
|
-
server.close
|
|
104
|
-
File.unlink(socket_path)
|
|
105
|
-
unless options[:noproc]
|
|
106
|
-
command = argv.empty? ? [ENV.fetch("SHELL", "/bin/sh")] : argv
|
|
107
|
-
shell = Expect.spawn(*command, log_stdout: false, raw_terminal: false, write_timeout: 5)
|
|
108
|
-
shell.winsize = input.winsize
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
# Announce readiness only after local echo/canonical input are disabled.
|
|
112
|
-
# A fast typist (or PTY driver) may send bytes immediately after the banner.
|
|
113
|
-
result = input.raw do
|
|
114
|
-
hint = options[:escape] ? "Escape #{options[:escape].inspect} ends this session." : "Escape disabled."
|
|
115
|
-
output.write("Kibitz connected. #{hint}\r\n")
|
|
116
|
-
relay(input: input, output: output, peer: peer, shell: shell,
|
|
117
|
-
escape: options[:escape], timeout: options[:timeout], log: log)
|
|
118
|
-
end
|
|
119
|
-
if result[:reason] == :process
|
|
120
|
-
shell.soft_close(timeout: 2)
|
|
121
|
-
status = shell.exit_code || 1
|
|
122
|
-
else
|
|
123
|
-
status = result[:reason] == :timeout ? 1 : 0
|
|
124
|
-
end
|
|
125
|
-
output.puts("\nKibitz ended (#{result[:reason]}).")
|
|
126
|
-
status
|
|
127
|
-
rescue OptionParser::ParseError, IOError, SystemCallError, Expect::SpawnError => error
|
|
128
|
-
warn "kibitz: #{error.message}"
|
|
129
|
-
1
|
|
130
|
-
ensure
|
|
131
|
-
shell&.close
|
|
132
|
-
peer&.close
|
|
133
|
-
server&.close unless server&.closed?
|
|
134
|
-
log&.close
|
|
135
|
-
File.unlink(socket_path) if socket_path && File.socket?(socket_path)
|
|
136
|
-
Dir.rmdir(directory) if directory && Dir.exist?(directory)
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
exit Kibitz.run(ARGV) if $PROGRAM_NAME == __FILE__
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "fileutils"
|
|
5
|
-
require "tmpdir"
|
|
6
|
-
require "time"
|
|
7
|
-
require_relative "../../test/support/kibitz_probe"
|
|
8
|
-
|
|
9
|
-
base = File.expand_path(ENV.fetch("EXPECT_LOG_DIR", "../../tmp/kibitz"), __dir__)
|
|
10
|
-
FileUtils.mkdir_p(base)
|
|
11
|
-
directory = Dir.mktmpdir("#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-", base)
|
|
12
|
-
report = { passed: false, cases: [], started_at: Time.now.utc.iso8601 }
|
|
13
|
-
begin
|
|
14
|
-
{
|
|
15
|
-
shared_shell: ->(dir) { KibitzProbe.shared_shell(dir) },
|
|
16
|
-
host_escape: ->(dir) { KibitzProbe.direct(dir, ending: :host) },
|
|
17
|
-
guest_escape: ->(dir) { KibitzProbe.direct(dir, ending: :guest) },
|
|
18
|
-
noescape: ->(dir) { KibitzProbe.noescape(dir) },
|
|
19
|
-
process_failure: ->(dir) { KibitzProbe.process_failure(dir) },
|
|
20
|
-
relay_timeout: ->(dir) { KibitzProbe.relay_timeout(dir) }
|
|
21
|
-
}.each do |name, probe|
|
|
22
|
-
path = File.join(directory, name.to_s)
|
|
23
|
-
Dir.mkdir(path, 0o700)
|
|
24
|
-
result = probe.call(path)
|
|
25
|
-
report[:cases] << result.merge(log_path: File.join(path, "session.log"))
|
|
26
|
-
puts "PASS #{name}: #{result[:checks].join(", ")}"
|
|
27
|
-
end
|
|
28
|
-
report[:passed] = true
|
|
29
|
-
rescue StandardError => error
|
|
30
|
-
report[:error] = "#{error.class}: #{error.message}"
|
|
31
|
-
ensure
|
|
32
|
-
report[:finished_at] = Time.now.utc.iso8601
|
|
33
|
-
path = File.join(directory, "report.json")
|
|
34
|
-
File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(JSON.pretty_generate(report)) }
|
|
35
|
-
puts "Report: #{path}"
|
|
36
|
-
end
|
|
37
|
-
abort(report[:error]) unless report[:passed]
|
data/examples/ssh_auto.rb
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "securerandom"
|
|
5
|
-
require "tempfile"
|
|
6
|
-
require "tmpdir"
|
|
7
|
-
require_relative "../lib/expect/pty"
|
|
8
|
-
|
|
9
|
-
# 在这里按顺序填写要执行的 macOS / POSIX shell 命令。
|
|
10
|
-
COMMANDS = ["id", "uname -srm", "sw_vers", "df -h /", "uptime"].freeze
|
|
11
|
-
PROMPT = "MACOS> "
|
|
12
|
-
|
|
13
|
-
if ARGV.delete("--help")
|
|
14
|
-
puts "用法:ruby examples/ssh_auto.rb [--no-interact]"
|
|
15
|
-
puts "自动 SSH 登录,执行 COMMANDS 后进入 interact;--no-interact 执行后退出。"
|
|
16
|
-
puts "环境变量:SSH_HOST、SSH_USER、SSH_PORT、SSH_KNOWN_HOSTS、EXPECT_PASSWORD、EXPECT_LOG_DIR。"
|
|
17
|
-
exit
|
|
18
|
-
end
|
|
19
|
-
interactive = !ARGV.delete("--no-interact")
|
|
20
|
-
abort "未知参数:#{ARGV.join(" ")}" unless ARGV.empty?
|
|
21
|
-
abort "interact 需要终端;批量执行请加 --no-interact" if interactive && !$stdin.tty?
|
|
22
|
-
|
|
23
|
-
host = ENV.fetch("SSH_HOST", "127.0.0.1")
|
|
24
|
-
user = ENV.fetch("SSH_USER", ENV.fetch("USER", "crate"))
|
|
25
|
-
port = Integer(ENV.fetch("SSH_PORT", "22"), 10)
|
|
26
|
-
abort "SSH_PORT 应为 1..65535" unless (1..65_535).cover?(port)
|
|
27
|
-
abort "SSH_HOST / SSH_USER 格式无效" if [host, user].any? do |value|
|
|
28
|
-
value.empty? || value.start_with?("-") || value.match?(/[\s\x00]/)
|
|
29
|
-
end
|
|
30
|
-
known_hosts = ENV.fetch("SSH_KNOWN_HOSTS", nil)
|
|
31
|
-
abort "连接其他主机时请设置 SSH_KNOWN_HOSTS" unless known_hosts || %w[127.0.0.1 ::1 localhost].include?(host)
|
|
32
|
-
|
|
33
|
-
password = ENV.delete("EXPECT_PASSWORD")&.dup
|
|
34
|
-
log = nil
|
|
35
|
-
begin
|
|
36
|
-
unless password
|
|
37
|
-
$stderr.print("SSH password: ")
|
|
38
|
-
password = ($stdin.tty? ? $stdin.noecho(&:gets) : $stdin.gets)&.chomp
|
|
39
|
-
$stderr.puts
|
|
40
|
-
end
|
|
41
|
-
abort "请输入单行密码" if password.nil? || password.empty? || password.match?(/[\r\n\x00]/)
|
|
42
|
-
|
|
43
|
-
Dir.mktmpdir("expect-ssh-") do |dir|
|
|
44
|
-
ssh_args = ["ssh", "-F", "/dev/null", "-tt", "-p", port.to_s,
|
|
45
|
-
"-o", "ConnectTimeout=5", "-o", "NumberOfPasswordPrompts=1",
|
|
46
|
-
"-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no",
|
|
47
|
-
"-o", "StrictHostKeyChecking=#{known_hosts ? "yes" : "accept-new"}",
|
|
48
|
-
"-o", "UserKnownHostsFile=#{known_hosts || File.join(dir, "known_hosts")}",
|
|
49
|
-
"-l", user, host, "env ENV= PS1=#{Shellwords.escape(PROMPT)} /bin/sh -i"]
|
|
50
|
-
|
|
51
|
-
Expect.spawn(*ssh_args, raw_pty: true, log_stdout: false, write_timeout: 5) do |ssh|
|
|
52
|
-
# 登录成功后才开启日志,密码不写入文件。
|
|
53
|
-
ssh.expect(/password:\s*\z/i, timeout: 10) or abort "未收到密码提示:#{ssh.error}"
|
|
54
|
-
ssh.write(password, "\n")
|
|
55
|
-
ssh.expect(PROMPT, timeout: 10) or abort "SSH 登录失败:#{ssh.error}"
|
|
56
|
-
password.replace("\0" * password.bytesize)
|
|
57
|
-
ssh.write("stty sane -echo; set +o emacs; set +o vi\n")
|
|
58
|
-
ssh.expect(PROMPT, timeout: 5) or abort "终端初始化失败:#{ssh.error}"
|
|
59
|
-
|
|
60
|
-
log_dir = ENV.fetch("EXPECT_LOG_DIR", File.expand_path("../tmp/ssh-auto", __dir__))
|
|
61
|
-
FileUtils.mkdir_p(log_dir)
|
|
62
|
-
log = Tempfile.create(["session-", ".log"], log_dir) # 0600,退出后保留
|
|
63
|
-
ssh.log_to(log)
|
|
64
|
-
puts "已登录 #{user}@#{host};日志:#{log.path}"
|
|
65
|
-
|
|
66
|
-
COMMANDS.each do |command|
|
|
67
|
-
puts "\n$ #{command}"
|
|
68
|
-
ssh.write_log("\n$ #{command}\n")
|
|
69
|
-
token = SecureRandom.hex(8)
|
|
70
|
-
# 远端拼接结束标记并带回退出码,避免把命令回显当成执行结果。
|
|
71
|
-
ssh.write("#{command}; printf '\\n__DONE_%s:%s\\n' '#{token}' \"$?\"\n")
|
|
72
|
-
ssh.expect(/\r?\n__DONE_#{token}:(\d+)\r?\n#{Regexp.escape(PROMPT)}/, timeout: 30) or abort "命令未完成:#{ssh.error}"
|
|
73
|
-
print ssh.before.gsub("\r\n", "\n")
|
|
74
|
-
abort "命令失败(退出码 #{ssh.captures.first}):#{command}" unless ssh.captures.first == "0"
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
if interactive
|
|
78
|
-
ssh.write("stty echo\n")
|
|
79
|
-
ssh.expect(PROMPT, timeout: 5) or abort "无法进入交互:#{ssh.error}"
|
|
80
|
-
puts "\n检查完成,进入 interact。输入 exit 或按 Ctrl-] 结束。"
|
|
81
|
-
$stdout.print(PROMPT)
|
|
82
|
-
$stdout.flush
|
|
83
|
-
next unless ssh.interact(input: $stdin, escape: "\x1d", output: $stdout).equal?(ssh)
|
|
84
|
-
else
|
|
85
|
-
ssh.write("exit\n")
|
|
86
|
-
end
|
|
87
|
-
ssh.soft_close(timeout: 3)
|
|
88
|
-
abort "SSH 退出异常:#{ssh.exit_code.inspect}" unless ssh.exit_code&.zero?
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
ensure
|
|
92
|
-
log&.close
|
|
93
|
-
password&.replace("\0" * password.bytesize)
|
|
94
|
-
end
|
data/examples/ssh_interact.rb
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Run from the checkout or the unpacked gem. Test helpers use only standard
|
|
4
|
-
# libraries; Minitest is not needed for this live SSH example.
|
|
5
|
-
require "io/console"
|
|
6
|
-
require "json"
|
|
7
|
-
require "fileutils"
|
|
8
|
-
require "tmpdir"
|
|
9
|
-
require "time"
|
|
10
|
-
require_relative "../test/support/interact_probe"
|
|
11
|
-
|
|
12
|
-
if ARGV.delete("--help")
|
|
13
|
-
puts <<~HELP
|
|
14
|
-
Usage: ruby examples/ssh_interact.rb [--auto]
|
|
15
|
-
|
|
16
|
-
Default: SSH login with visible typing; Ctrl-] returns to expect, exit ends SSH.
|
|
17
|
-
--auto: Drive a real local PTY to verify commands, Ctrl-C, Ctrl-] and reentry.
|
|
18
|
-
|
|
19
|
-
Environment: SSH_HOST (127.0.0.1), SSH_USER (current user), SSH_PORT (22),
|
|
20
|
-
SSH_KNOWN_HOSTS, EXPECT_PASSWORD, EXPECT_LOG_DIR.
|
|
21
|
-
Password input is hidden. Non-loopback hosts require SSH_KNOWN_HOSTS.
|
|
22
|
-
Logs and JSON reports default to tmp/ssh-interact/ in this project.
|
|
23
|
-
HELP
|
|
24
|
-
exit
|
|
25
|
-
end
|
|
26
|
-
automatic = !ARGV.delete("--auto").nil?
|
|
27
|
-
abort "unknown arguments: #{ARGV.join(" ")} (use --help)" unless ARGV.empty?
|
|
28
|
-
abort "manual interact requires a terminal; use --auto for unattended testing" unless automatic || $stdin.tty?
|
|
29
|
-
|
|
30
|
-
host = ENV.fetch("SSH_HOST", "127.0.0.1")
|
|
31
|
-
user = ENV.fetch("SSH_USER", ENV.fetch("USER", "crate"))
|
|
32
|
-
port = Integer(ENV.fetch("SSH_PORT", "22"), 10)
|
|
33
|
-
ScriptProbe.check((1..65_535).cover?(port), "SSH_PORT must be between 1 and 65535")
|
|
34
|
-
ScriptProbe.check([host, user].none? do |value|
|
|
35
|
-
value.empty? || value.start_with?("-") || value.match?(/[\s\x00]/)
|
|
36
|
-
end, "invalid SSH host or user")
|
|
37
|
-
known_hosts = ENV.fetch("SSH_KNOWN_HOSTS", nil)
|
|
38
|
-
ScriptProbe.check(known_hosts || %w[127.0.0.1 ::1 localhost].include?(host),
|
|
39
|
-
"SSH_KNOWN_HOSTS is required for remote hosts")
|
|
40
|
-
|
|
41
|
-
password = ENV.delete("EXPECT_PASSWORD")&.dup
|
|
42
|
-
unless password
|
|
43
|
-
$stderr.print("SSH password: ")
|
|
44
|
-
password = ($stdin.tty? ? $stdin.noecho(&:gets) : $stdin.gets)&.chomp
|
|
45
|
-
$stderr.puts
|
|
46
|
-
end
|
|
47
|
-
ScriptProbe.check(password && !password.empty? && !password.match?(/[\r\n\x00]/), "a single-line password is required")
|
|
48
|
-
base = File.expand_path(ENV.fetch("EXPECT_LOG_DIR", File.expand_path("../tmp/ssh-interact", __dir__)))
|
|
49
|
-
FileUtils.mkdir_p(base)
|
|
50
|
-
directory = Dir.mktmpdir("#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}-", base)
|
|
51
|
-
log_path = File.join(directory, "session.log")
|
|
52
|
-
report_path = File.join(directory, "report.json")
|
|
53
|
-
report = { mode: automatic ? "automatic" : "manual", host: host, port: port, user: user,
|
|
54
|
-
started_at: Time.now.utc.iso8601, passed: false, cases: [], checks: [] }
|
|
55
|
-
session = nil
|
|
56
|
-
local_terminal = nil
|
|
57
|
-
|
|
58
|
-
begin
|
|
59
|
-
Dir.mktmpdir("expect-known-hosts-") do |temporary|
|
|
60
|
-
policy = known_hosts ? "yes" : "accept-new"
|
|
61
|
-
hosts_path = known_hosts || File.join(temporary, "known_hosts")
|
|
62
|
-
args = ["ssh", "-F", "/dev/null", "-tt", "-p", port.to_s,
|
|
63
|
-
"-o", "ConnectTimeout=5", "-o", "NumberOfPasswordPrompts=1",
|
|
64
|
-
"-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no",
|
|
65
|
-
"-o", "StrictHostKeyChecking=#{policy}", "-o", "UserKnownHostsFile=#{hosts_path}",
|
|
66
|
-
"-l", user, host, "env ENV= PS1=#{Shellwords.escape(ScriptProbe::PROMPT)} /bin/sh -i"]
|
|
67
|
-
session = Expect.spawn(*args, raw_pty: true, log_stdout: false, log_listeners: false,
|
|
68
|
-
debug_level: 0, write_timeout: 5)
|
|
69
|
-
login = session.expect(/password:\s*\z/i, /Permission denied/i, timeout: 10)
|
|
70
|
-
ScriptProbe.check(login == 1, "SSH password prompt missing (#{session.error || "authentication rejected"})")
|
|
71
|
-
session.write(password, "\n")
|
|
72
|
-
runner = ScriptProbe::Runner.new(session, timeout: 10).ready!
|
|
73
|
-
InteractProbe.prepare(session, echo: !automatic)
|
|
74
|
-
File.open(log_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.truncate(0) }
|
|
75
|
-
session.log_to(log_path)
|
|
76
|
-
|
|
77
|
-
remote_exit = false
|
|
78
|
-
if automatic
|
|
79
|
-
report.merge!(InteractProbe.verify(session, user: user))
|
|
80
|
-
else
|
|
81
|
-
local_terminal = Expect.open($stdin)
|
|
82
|
-
initial_mode = InteractProbe.configuration(local_terminal)
|
|
83
|
-
puts "Logged in as #{user}@#{host}. Enter commands; press Ctrl-] to return to expect."
|
|
84
|
-
puts "Log: #{log_path}"
|
|
85
|
-
# prepare consumed the initial prompt while configuring remote echo.
|
|
86
|
-
$stdout.print(ScriptProbe::PROMPT)
|
|
87
|
-
$stdout.flush
|
|
88
|
-
# This is the actual public API handoff: stdin -> SSH, SSH -> stdout.
|
|
89
|
-
returned = session.interact(input: $stdin, escape: InteractProbe::ESCAPE, output: $stdout)
|
|
90
|
-
ScriptProbe.check(InteractProbe.configuration(local_terminal) == initial_mode,
|
|
91
|
-
"stdin terminal mode was not restored")
|
|
92
|
-
report[:local_terminal_restored] = true
|
|
93
|
-
report[:checks] += %w[real_stdin_stdout terminal_restored]
|
|
94
|
-
if returned.equal?(session)
|
|
95
|
-
remote_exit = true
|
|
96
|
-
report[:completion] = "remote_exit"
|
|
97
|
-
session.soft_close(timeout: 3)
|
|
98
|
-
ScriptProbe.check(session.closed? && session.exit_code&.zero?,
|
|
99
|
-
"SSH exited with status #{session.exit_code.inspect}")
|
|
100
|
-
report[:checks] << "remote_eof"
|
|
101
|
-
else
|
|
102
|
-
ScriptProbe.check(returned.is_a?(Expect) && returned.to_io.equal?($stdin), "unexpected interact termination")
|
|
103
|
-
ScriptProbe.check(session.alive?, "remote session ended during interaction")
|
|
104
|
-
report[:completion] = "local_escape"
|
|
105
|
-
report[:checks] += %w[ctrl_bracket_escape remote_alive]
|
|
106
|
-
puts "\nReturned from interact; verifying automated expect resumes..."
|
|
107
|
-
InteractProbe.prepare(session)
|
|
108
|
-
runner.run(
|
|
109
|
-
"expect_after_manual", "printf 'MANUAL_AUTOMATION_RESUMED\\n'",
|
|
110
|
-
expected_status: 0, expected_output: "MANUAL_AUTOMATION_RESUMED\n"
|
|
111
|
-
)
|
|
112
|
-
report[:cases] = runner.results
|
|
113
|
-
report[:checks] << "expect_resume"
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
runner.finish! unless remote_exit
|
|
118
|
-
report[:ssh_exit_code] = session.exit_code
|
|
119
|
-
text = ScriptProbe.normalize(File.binread(log_path))
|
|
120
|
-
report[:cases].each do |result|
|
|
121
|
-
output = result.fetch(:output).b
|
|
122
|
-
ScriptProbe.check(text.scan(Regexp.new(Regexp.escape(output), Regexp::NOENCODING)).length == 1,
|
|
123
|
-
"#{result[:name]}: log output missing or duplicated")
|
|
124
|
-
end
|
|
125
|
-
unless remote_exit
|
|
126
|
-
ScriptProbe.check(text.scan("SESSION_FINAL_TAIL\n").length == 1,
|
|
127
|
-
"shutdown tail missing or duplicated")
|
|
128
|
-
end
|
|
129
|
-
ScriptProbe.check(!text.include?(InteractProbe::TAIL), "local escape tail leaked to SSH") if automatic
|
|
130
|
-
ScriptProbe.check(!text.include?(password.b), "password detected in log")
|
|
131
|
-
report[:checks] += %w[unique_log_output eof_drain password_absent clean_ssh_exit]
|
|
132
|
-
report[:passed] = true
|
|
133
|
-
end
|
|
134
|
-
rescue StandardError => error
|
|
135
|
-
report[:error] = "#{error.class}: #{error.message}".gsub(password, "[REDACTED]")
|
|
136
|
-
ensure
|
|
137
|
-
session&.close
|
|
138
|
-
local_terminal&.close
|
|
139
|
-
if File.file?(log_path)
|
|
140
|
-
bytes = File.binread(log_path)
|
|
141
|
-
if bytes.include?(password.b)
|
|
142
|
-
File.binwrite(log_path, bytes.gsub(password.b, "[REDACTED]"))
|
|
143
|
-
report[:passed] = false
|
|
144
|
-
report[:error] = "password detected and removed from log"
|
|
145
|
-
end
|
|
146
|
-
report[:log_bytes] = File.size(log_path)
|
|
147
|
-
report[:log_sha256] = Digest::SHA256.file(log_path).hexdigest
|
|
148
|
-
end
|
|
149
|
-
password.replace("\0" * password.bytesize)
|
|
150
|
-
report[:finished_at] = Time.now.utc.iso8601
|
|
151
|
-
report[:log_path] = log_path
|
|
152
|
-
File.open(report_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write(JSON.pretty_generate(report)) }
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
puts "Log: #{log_path}"
|
|
156
|
-
puts "Report: #{report_path}"
|
|
157
|
-
abort(report[:error]) unless report[:passed]
|
|
158
|
-
puts "PASS #{report[:mode]} interact: #{report[:cases].length} command checks, " \
|
|
159
|
-
"#{report[:checks].length} interaction/log checks; SSH exit=0"
|
data/examples/ssh_login.rb
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# Optional real SSH integration check. Password input is hidden and never logged.
|
|
4
|
-
require "io/console"
|
|
5
|
-
require "tmpdir"
|
|
6
|
-
require "securerandom"
|
|
7
|
-
require_relative "../lib/expect/pty"
|
|
8
|
-
|
|
9
|
-
host = ENV.fetch("SSH_HOST", "127.0.0.1")
|
|
10
|
-
user = ENV.fetch("SSH_USER", ENV.fetch("USER", "crate"))
|
|
11
|
-
abort "invalid SSH host or user" if [host, user].any? do |value|
|
|
12
|
-
value.empty? || value.start_with?("-") || value.match?(/[\s\x00]/)
|
|
13
|
-
end
|
|
14
|
-
password = ENV.delete("EXPECT_PASSWORD")&.dup
|
|
15
|
-
unless password
|
|
16
|
-
$stderr.print("SSH password: ")
|
|
17
|
-
password = $stdin.tty? ? $stdin.noecho(&:gets) : $stdin.gets
|
|
18
|
-
$stderr.puts
|
|
19
|
-
password = password&.chomp
|
|
20
|
-
end
|
|
21
|
-
abort "password is required" if password.nil? || password.empty?
|
|
22
|
-
|
|
23
|
-
begin
|
|
24
|
-
Dir.mktmpdir("expect-ssh-") do |dir|
|
|
25
|
-
# Isolated known_hosts keeps this example independent of user SSH settings.
|
|
26
|
-
# For non-loopback hosts, use an existing trusted known_hosts file.
|
|
27
|
-
known_hosts = ENV.fetch("SSH_KNOWN_HOSTS", nil)
|
|
28
|
-
abort "SSH_KNOWN_HOSTS is required for remote hosts" unless known_hosts || %w[127.0.0.1 ::1
|
|
29
|
-
localhost].include?(host)
|
|
30
|
-
policy = known_hosts ? "yes" : "accept-new"
|
|
31
|
-
known_hosts ||= File.join(dir, "known_hosts")
|
|
32
|
-
arguments = ["ssh", "-F", "/dev/null", "-tt", "-o", "ConnectTimeout=5",
|
|
33
|
-
"-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no",
|
|
34
|
-
"-o", "NumberOfPasswordPrompts=1", "-o", "StrictHostKeyChecking=#{policy}",
|
|
35
|
-
"-o", "UserKnownHostsFile=#{known_hosts}", "-l", user, host,
|
|
36
|
-
"env PS1='EXPECT_SHELL> ' /bin/sh -i"]
|
|
37
|
-
Expect.spawn(*arguments, log_stdout: false, raw_pty: true) do |session|
|
|
38
|
-
prompt = session.expect(/password:\s*\z/i, /Permission denied/i, timeout: 10)
|
|
39
|
-
abort "SSH password prompt not received (#{session.error || "authentication rejected"})" unless prompt == 1
|
|
40
|
-
session.write(password, "\n")
|
|
41
|
-
password.replace("\0" * password.bytesize)
|
|
42
|
-
|
|
43
|
-
# Start a command after authentication. The marker is assembled remotely
|
|
44
|
-
# from two quoted arguments, so terminal command echo cannot satisfy it.
|
|
45
|
-
login = session.expect("EXPECT_SHELL> ", /Permission denied/i, timeout: 10)
|
|
46
|
-
abort "SSH login failed (#{session.error || "authentication rejected"})" unless login == 1
|
|
47
|
-
token = SecureRandom.hex(12)
|
|
48
|
-
session.write("printf '\\n%s%s\\n' 'EXPECT_OK_' '#{token}'; id -un; tty\n")
|
|
49
|
-
marker = session.expect(/EXPECT_OK_#{Regexp.escape(token)}\r?\n/, timeout: 10)
|
|
50
|
-
abort "remote command did not run (#{session.error})" unless marker
|
|
51
|
-
identity = session.expect(%r{([^\r\n]+)\r?\n(/dev/[^\r\n]+)\r?\n}, timeout: 5)
|
|
52
|
-
abort "remote identity/TTY response missing" unless identity
|
|
53
|
-
abort "unexpected SSH user" unless session.captures.first == user.b
|
|
54
|
-
puts "SSH password login verified: #{user}@#{host}"
|
|
55
|
-
puts "Remote identity: #{session.captures.first}; TTY: #{session.captures.last}"
|
|
56
|
-
session.write("exit\n")
|
|
57
|
-
session.soft_close(timeout: 5)
|
|
58
|
-
abort "SSH exit status #{session.exit_code.inspect}" unless session.exit_code&.zero?
|
|
59
|
-
puts "SSH exited cleanly (0)"
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
ensure
|
|
63
|
-
password&.replace("\0" * password.bytesize)
|
|
64
|
-
end
|
data/expect-pty.gemspec
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "lib/expect/version"
|
|
4
|
-
|
|
5
|
-
Gem::Specification.new do |spec|
|
|
6
|
-
spec.name = "expect-pty"
|
|
7
|
-
spec.version = Expect::VERSION
|
|
8
|
-
spec.authors = ["expect-pty contributors"]
|
|
9
|
-
spec.homepage = "https://github.com/gatework/expect-ruby"
|
|
10
|
-
spec.summary = "Ruby PTY automation with the Expect.pm interaction model"
|
|
11
|
-
spec.description = "Automate interactive programs with exact and regexp matching, " \
|
|
12
|
-
"Ruby blocks, multi-session waits, logging and terminal interaction."
|
|
13
|
-
spec.license = "MIT"
|
|
14
|
-
spec.required_ruby_version = ">= 3.2"
|
|
15
|
-
spec.files = Dir[
|
|
16
|
-
"lib/**/*.rb", "examples/**/*.rb", "examples/**/*.md", "docs/**/*.md", "benchmark/**/*.rb",
|
|
17
|
-
"test/**/*.rb", "test/**/*.sh", "test/**/*.md", "Gemfile", "Rakefile",
|
|
18
|
-
".rubocop.yml", "expect-pty.gemspec", "README.md", "CONTRIBUTING.md", "LICENSE", "CHANGELOG.md",
|
|
19
|
-
"script/ci", "script/release.rb"
|
|
20
|
-
]
|
|
21
|
-
spec.require_paths = ["lib"]
|
|
22
|
-
# 显式声明可独立升级的标准库 gem,供应用的 Bundler 解析完整运行时依赖。
|
|
23
|
-
# pty 是 Ruby 自带的 POSIX 扩展,不是独立 gem。
|
|
24
|
-
spec.add_dependency "forwardable", "~> 1.3"
|
|
25
|
-
spec.add_dependency "io-console", ">= 0.6", "< 1.0"
|
|
26
|
-
spec.add_dependency "io-wait", ">= 0.3", "< 1.0"
|
|
27
|
-
spec.add_dependency "shellwords", ">= 0.1", "< 1.0"
|
|
28
|
-
spec.add_dependency "stringio", "~> 3.0"
|
|
29
|
-
spec.metadata["rubygems_mfa_required"] = "true"
|
|
30
|
-
spec.metadata["source_code_uri"] = spec.homepage
|
|
31
|
-
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
32
|
-
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
33
|
-
end
|
data/script/ci
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
set -euo pipefail
|
|
3
|
-
|
|
4
|
-
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
|
5
|
-
|
|
6
|
-
bundle exec rake
|
|
7
|
-
bundle exec ruby examples/dialogue.rb
|
|
8
|
-
|
|
9
|
-
# 只检查小规模工作负载的正确性,不为共享 CI 机器设置性能阈值。
|
|
10
|
-
for benchmark in matching relay send_slow scaling redactor; do
|
|
11
|
-
bundle exec ruby "benchmark/${benchmark}.rb" --smoke
|
|
12
|
-
done
|
|
13
|
-
|
|
14
|
-
mkdir -p tmp/ci
|
|
15
|
-
gem_version="$(ruby -Ilib -rexpect/version -e 'print Expect::VERSION')"
|
|
16
|
-
gem_file="tmp/ci/expect-pty-${gem_version}.gem"
|
|
17
|
-
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
18
|
-
export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"
|
|
19
|
-
fi
|
|
20
|
-
gem build expect-pty.gemspec --output "$gem_file"
|
|
21
|
-
|
|
22
|
-
# 在独立目录安装刚构建的包,避免源码路径或 Bundler 掩盖打包遗漏。
|
|
23
|
-
smoke_dir="$(mktemp -d)"
|
|
24
|
-
trap 'rm -rf "$smoke_dir"' EXIT
|
|
25
|
-
gem_file="$(pwd)/$gem_file"
|
|
26
|
-
bundler_version="$(bundle exec ruby -rbundler -e 'print Bundler::VERSION')"
|
|
27
|
-
bundler_gem="$(bundle exec ruby -e 'print Gem.loaded_specs.fetch("bundler").cache_file')"
|
|
28
|
-
|
|
29
|
-
(
|
|
30
|
-
cd "$smoke_dir"
|
|
31
|
-
# 清除 Bundler 的自动加载与路径设置;Ruby 4 还会通过 BUNDLER_SETUP 自动启用 Bundler。
|
|
32
|
-
for bundle_variable in ${!BUNDLE_@} ${!BUNDLER_@}; do
|
|
33
|
-
unset "$bundle_variable"
|
|
34
|
-
done
|
|
35
|
-
unset RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS
|
|
36
|
-
export GEM_HOME="$smoke_dir/gems" GEM_PATH="$smoke_dir/gems"
|
|
37
|
-
gem install --local --no-document "$gem_file"
|
|
38
|
-
|
|
39
|
-
cat > smoke.rb <<'RUBY'
|
|
40
|
-
require "expect/pty"
|
|
41
|
-
require "rbconfig"
|
|
42
|
-
|
|
43
|
-
spec = Gem.loaded_specs.fetch("expect-pty")
|
|
44
|
-
# 临时目录可能经由符号链接访问,先统一为实际路径再检查。
|
|
45
|
-
install_root = File.realpath(ENV.fetch("GEM_HOME"))
|
|
46
|
-
abort "gem was loaded outside the isolated install" unless File.realpath(spec.full_gem_path).start_with?("#{install_root}/")
|
|
47
|
-
abort "Bundler leaked into the plain gem smoke" if ARGV.first == "plain" && defined?(Bundler)
|
|
48
|
-
unexpected = %w[minitest rake rubocop parallel logger].select { |name| Gem.loaded_specs.key?(name) }
|
|
49
|
-
abort "development dependencies leaked into the application: #{unexpected.join(', ')}" unless unexpected.empty?
|
|
50
|
-
spec.runtime_dependencies.each do |dependency|
|
|
51
|
-
loaded = Gem.loaded_specs.fetch(dependency.name)
|
|
52
|
-
abort "incompatible dependency: #{dependency}" unless dependency.matches_spec?(loaded)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# 检查实际使用的标准库已在包元数据中声明,默认 gem 可加载不能代替依赖声明。
|
|
56
|
-
%w[forwardable io-console io-wait shellwords stringio].each do |name|
|
|
57
|
-
abort "undeclared runtime dependency: #{name}" unless spec.runtime_dependencies.any? { |dependency| dependency.name == name }
|
|
58
|
-
end
|
|
59
|
-
child = 'STDOUT.sync = true; puts "ready"; puts "reply:#{$stdin.gets&.strip}"'
|
|
60
|
-
Expect.spawn(RbConfig.ruby, "--disable-gems", "-e", child, raw_pty: true, log_stdout: false) do |session|
|
|
61
|
-
session.winsize = [30, 100]
|
|
62
|
-
abort "io-console terminal size failed" unless session.winsize == [30, 100]
|
|
63
|
-
# BSD stty 设置模式会清空待读输出,先收取提示,使子进程停在等待输入的位置。
|
|
64
|
-
abort "installed gem did not receive the PTY prompt" unless session.expect("ready", timeout: 5)
|
|
65
|
-
mode = session.stty
|
|
66
|
-
abort "installed stty query failed" if mode.empty?
|
|
67
|
-
session.stty(mode)
|
|
68
|
-
|
|
69
|
-
session.puts("expect-ruby")
|
|
70
|
-
abort "installed gem PTY dialogue failed" unless session.expect("reply:expect-ruby", timeout: 5)
|
|
71
|
-
status = session.soft_close(timeout: 5)
|
|
72
|
-
abort "installed gem child did not exit successfully" unless status&.success?
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# 使用已安装包验证公共过滤器随包分发,且结构化诊断无需加载可选的 Logger。
|
|
76
|
-
filter = Expect::Redactor.new(["secret"], replacement: "[REDACTED]")
|
|
77
|
-
filtered = filter.append("sec") + filter.append("ret!") + filter.finish
|
|
78
|
-
abort "installed public redactor failed" unless filtered == "[REDACTED]!"
|
|
79
|
-
abort "installed complete-text redactor failed" unless Expect::Redactor.redact("sec", ["secret"]) == "sec"
|
|
80
|
-
reader, producer = IO.pipe
|
|
81
|
-
events = []
|
|
82
|
-
transcript = StringIO.new
|
|
83
|
-
begin
|
|
84
|
-
Expect.open(reader, own: true, buffer_limit: 5, debug_level: 2,
|
|
85
|
-
diagnostic_output: ->(event) { events << event }) do |session|
|
|
86
|
-
session.redact("secret")
|
|
87
|
-
session.log_to(transcript)
|
|
88
|
-
session.buffer = "ready"
|
|
89
|
-
expired = session.expect_result("ready", deadline: Expect.monotonic)
|
|
90
|
-
abort "expired deadline consumed buffered text" unless expired.timeout? && session.buffer == "ready"
|
|
91
|
-
abort "zero timeout lost buffered text" unless session.expect("ready", timeout: 0) == 1
|
|
92
|
-
producer.write("secret!")
|
|
93
|
-
producer.close
|
|
94
|
-
result = session.expect_result(:eof, deadline: Expect.monotonic + 5)
|
|
95
|
-
abort "installed gem did not preserve capped input" unless result.eof? && result.before == "cret!"
|
|
96
|
-
abort "incorrect installed buffer accounting" unless session.buffer_discarded_bytes == 2
|
|
97
|
-
abort "installed redaction failed" unless transcript.string == "[FILTERED]!"
|
|
98
|
-
abort "diagnostics leaked secret" if events.any? { |event| event[:message].include?("secret") }
|
|
99
|
-
abort "filtered diagnostic missing" unless events.any? { |event| event[:message].include?("[FILTERED]") }
|
|
100
|
-
end
|
|
101
|
-
ensure
|
|
102
|
-
reader.close unless reader.closed?
|
|
103
|
-
producer.close unless producer.closed?
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
puts "Installed #{spec.full_name}: #{ARGV.fetch(0)} PTY, core contracts and runtime dependencies passed"
|
|
107
|
-
RUBY
|
|
108
|
-
ruby smoke.rb plain
|
|
109
|
-
|
|
110
|
-
# 消费方仅声明当前 Gem,不能借用项目 Gemfile 中的测试和发布依赖。
|
|
111
|
-
# 单独安装本次开发检查使用的 Bundler,避免依赖宿主 Ruby 的默认 Bundler 副本。
|
|
112
|
-
if [ -f "$bundler_gem" ]; then
|
|
113
|
-
gem install --local --no-document "$bundler_gem"
|
|
114
|
-
else
|
|
115
|
-
gem install --no-document bundler --version "$bundler_version"
|
|
116
|
-
fi
|
|
117
|
-
export BUNDLE_IGNORE_CONFIG=true
|
|
118
|
-
printf 'source "https://rubygems.org"\ngem "expect-pty", "= %s", require: "expect/pty"\n' "$gem_version" > Gemfile
|
|
119
|
-
bundle lock --local
|
|
120
|
-
bundle exec ruby -rbundler -e 'Bundler.require; load "smoke.rb"' bundler
|
|
121
|
-
)
|