expect-pty 0.2.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.
- checksums.yaml +7 -0
- data/.rubocop.yml +29 -0
- data/CHANGELOG.md +25 -0
- data/Gemfile +10 -0
- data/LICENSE +21 -0
- data/README.md +264 -0
- data/Rakefile +38 -0
- data/docs/COMPATIBILITY.md +65 -0
- data/docs/RELEASING.md +55 -0
- data/docs/VERIFICATION.md +146 -0
- data/examples/dialogue.rb +30 -0
- data/examples/kibitz/README.md +73 -0
- data/examples/kibitz/kibitz.rb +139 -0
- data/examples/kibitz/test_kibitz.rb +37 -0
- data/examples/ssh_auto.rb +94 -0
- data/examples/ssh_interact.rb +159 -0
- data/examples/ssh_login.rb +64 -0
- data/expect-pty.gemspec +25 -0
- data/lib/expect/configuration.rb +113 -0
- data/lib/expect/engine.rb +141 -0
- data/lib/expect/interconnect.rb +170 -0
- data/lib/expect/pattern.rb +62 -0
- data/lib/expect/pattern_list.rb +90 -0
- data/lib/expect/pty.rb +4 -0
- data/lib/expect/resources.rb +63 -0
- data/lib/expect/result.rb +14 -0
- data/lib/expect/version.rb +6 -0
- data/lib/expect.rb +591 -0
- data/script/ci +44 -0
- data/script/release.rb +267 -0
- data/test/compare_upstream.rb +157 -0
- data/test/configuration_test.rb +90 -0
- data/test/edge_case_test.rb +183 -0
- data/test/fixtures/ssh_scripts/01_identity.sh +4 -0
- data/test/fixtures/ssh_scripts/02_output.sh +5 -0
- data/test/fixtures/ssh_scripts/03_delayed.sh +6 -0
- data/test/fixtures/ssh_scripts/04_failure.sh +2 -0
- data/test/fixtures/ssh_scripts/05_recovery.sh +3 -0
- data/test/integration/README.md +90 -0
- data/test/integration/ssh_scripts.rb +94 -0
- data/test/interact_test.rb +151 -0
- data/test/interconnect_test.rb +226 -0
- data/test/io_test.rb +184 -0
- data/test/kibitz_test.rb +45 -0
- data/test/matching_test.rb +178 -0
- data/test/multi_session_test.rb +66 -0
- data/test/process_test.rb +244 -0
- data/test/release_test.rb +153 -0
- data/test/ruby_api_test.rb +515 -0
- data/test/script_logging_test.rb +124 -0
- data/test/support/interact_probe.rb +125 -0
- data/test/support/kibitz_probe.rb +177 -0
- data/test/support/script_probe.rb +157 -0
- data/test/test_helper.rb +58 -0
- data/test/timeout_test.rb +170 -0
- metadata +97 -0
|
@@ -0,0 +1,159 @@
|
|
|
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"
|
|
@@ -0,0 +1,64 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
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",
|
|
17
|
+
"test/**/*.rb", "test/**/*.sh", "test/**/*.md", "Gemfile", "Rakefile",
|
|
18
|
+
".rubocop.yml", "expect-pty.gemspec", "README.md", "LICENSE", "CHANGELOG.md", "script/ci", "script/release.rb"
|
|
19
|
+
]
|
|
20
|
+
spec.require_paths = ["lib"]
|
|
21
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
22
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
23
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
24
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
25
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Expect
|
|
4
|
+
# 集中校验会话配置。类级默认值以冻结快照发布,每个会话再构造独立副本。
|
|
5
|
+
class Configuration
|
|
6
|
+
# 会话通过 Forwardable 委托这些属性;to_h 使用同一清单生成配置副本。
|
|
7
|
+
ATTRIBUTES = %i[
|
|
8
|
+
timeout write_timeout buffer_limit debug_level raw_pty preserve_buffer
|
|
9
|
+
log_stdout log_listeners raw_terminal reset_timeout_on_read graceful_close
|
|
10
|
+
].freeze
|
|
11
|
+
# 布尔属性同时提供普通读方法和问号查询,写入统一采用 Ruby 真值规则。
|
|
12
|
+
PREDICATES = %i[
|
|
13
|
+
raw_pty? preserve_buffer? log_stdout? log_listeners? raw_terminal?
|
|
14
|
+
reset_timeout_on_read? graceful_close?
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :timeout, :write_timeout, :buffer_limit, :debug_level, :raw_pty, :preserve_buffer, :log_stdout,
|
|
18
|
+
:log_listeners, :raw_terminal, :reset_timeout_on_read, :graceful_close
|
|
19
|
+
|
|
20
|
+
# 通过 setter 校验构造参数,确保默认值、构造覆盖和后续赋值遵守同一规则。
|
|
21
|
+
def initialize(timeout: nil, write_timeout: nil, buffer_limit: nil, debug_level: 0,
|
|
22
|
+
raw_pty: false, preserve_buffer: false, log_stdout: false,
|
|
23
|
+
log_listeners: true, raw_terminal: true, reset_timeout_on_read: false,
|
|
24
|
+
graceful_close: false)
|
|
25
|
+
self.timeout = timeout
|
|
26
|
+
self.write_timeout = write_timeout
|
|
27
|
+
self.buffer_limit = buffer_limit
|
|
28
|
+
self.debug_level = debug_level
|
|
29
|
+
self.raw_pty = raw_pty
|
|
30
|
+
self.preserve_buffer = preserve_buffer
|
|
31
|
+
self.log_stdout = log_stdout
|
|
32
|
+
self.log_listeners = log_listeners
|
|
33
|
+
self.raw_terminal = raw_terminal
|
|
34
|
+
self.reset_timeout_on_read = reset_timeout_on_read
|
|
35
|
+
self.graceful_close = graceful_close
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# 设置匹配等待的默认秒数;nil 表示无限等待,0 表示只轮询现有数据。
|
|
39
|
+
def timeout=(value)
|
|
40
|
+
@timeout = Expect.duration(value)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# 设置写入背压的等待期限;先转换和校验,失败时保留原配置。
|
|
44
|
+
def write_timeout=(value)
|
|
45
|
+
@write_timeout = Expect.duration(value)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# 限制接收缓冲保留的尾部字节数;正整数为上限,nil 为无限。
|
|
49
|
+
def buffer_limit=(value)
|
|
50
|
+
unless value.nil? || (value.is_a?(Integer) && value.positive?)
|
|
51
|
+
raise ArgumentError, "buffer_limit must be a positive Integer or nil"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@buffer_limit = value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# 设置诊断详细程度:0 关闭,1 生命周期与匹配,2 收发内容,3 缓冲内容。
|
|
58
|
+
def debug_level=(value)
|
|
59
|
+
unless value.is_a?(Integer) && (0..3).cover?(value)
|
|
60
|
+
raise ArgumentError, "debug_level must be an Integer between 0 and 3"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
@debug_level = value
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 控制 spawn 前是否将子进程终端设为 raw,关闭回显和换行转换。
|
|
67
|
+
def raw_pty=(value)
|
|
68
|
+
@raw_pty = !!value
|
|
69
|
+
end
|
|
70
|
+
alias raw_pty? raw_pty
|
|
71
|
+
|
|
72
|
+
# 控制匹配成功后是否保留完整缓冲;启用时由继续回调自行消费匹配内容。
|
|
73
|
+
def preserve_buffer=(value)
|
|
74
|
+
@preserve_buffer = !!value
|
|
75
|
+
end
|
|
76
|
+
alias preserve_buffer? preserve_buffer
|
|
77
|
+
|
|
78
|
+
# 控制接收字节是否同步输出到当前 $stdout;默认关闭。
|
|
79
|
+
def log_stdout=(value)
|
|
80
|
+
@log_stdout = !!value
|
|
81
|
+
end
|
|
82
|
+
alias log_stdout? log_stdout
|
|
83
|
+
|
|
84
|
+
# 控制接收字节是否转发给监听器,与 stdout 和日志目标分别管理。
|
|
85
|
+
def log_listeners=(value)
|
|
86
|
+
@log_listeners = !!value
|
|
87
|
+
end
|
|
88
|
+
alias log_listeners? log_listeners
|
|
89
|
+
|
|
90
|
+
# 控制人工转接期间是否自动设置并恢复终端模式。
|
|
91
|
+
def raw_terminal=(value)
|
|
92
|
+
@raw_terminal = !!value
|
|
93
|
+
end
|
|
94
|
+
alias raw_terminal? raw_terminal
|
|
95
|
+
|
|
96
|
+
# 控制收到任何新数据时是否刷新匹配期限,适用于按静默时长判断超时。
|
|
97
|
+
def reset_timeout_on_read=(value)
|
|
98
|
+
@reset_timeout_on_read = !!value
|
|
99
|
+
end
|
|
100
|
+
alias reset_timeout_on_read? reset_timeout_on_read
|
|
101
|
+
|
|
102
|
+
# 控制通用 close 是否先软关闭;最终资源清理仍由硬关闭兜底。
|
|
103
|
+
def graceful_close=(value)
|
|
104
|
+
@graceful_close = !!value
|
|
105
|
+
end
|
|
106
|
+
alias graceful_close? graceful_close
|
|
107
|
+
|
|
108
|
+
# 导出新的属性 Hash,用于构造会话副本或发布下一份默认配置。
|
|
109
|
+
def to_h
|
|
110
|
+
ATTRIBUTES.to_h { |name| [name, public_send(name)] }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Expect
|
|
4
|
+
# 驱动一次单会话或多会话匹配,管理模式优先级、EOF 和共享期限,不接管 IO 所有权。
|
|
5
|
+
class Engine
|
|
6
|
+
# 固定本次参与的会话及初始期限;已处理 EOF 的会话仅从本次等待中移除。
|
|
7
|
+
def initialize(patterns, timeout)
|
|
8
|
+
@patterns = patterns
|
|
9
|
+
@sessions = patterns.sessions
|
|
10
|
+
@timeout = Expect.duration(timeout)
|
|
11
|
+
@deadline = next_deadline
|
|
12
|
+
@handled_eof = []
|
|
13
|
+
@polled = false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# 运行匹配状态机;内部 :retry 表示继续循环,最终返回一个 Result。
|
|
17
|
+
def run
|
|
18
|
+
@sessions.each { |session| session.__send__(:reset_result) }
|
|
19
|
+
loop do
|
|
20
|
+
# 先消费已缓冲的匹配,再处理 EOF,最后读取;避免进程退出时丢失最后一个匹配。
|
|
21
|
+
result = if (matched = find_match)
|
|
22
|
+
handle_match(*matched)
|
|
23
|
+
elsif (session = unhandled_eof)
|
|
24
|
+
handle_eof(session)
|
|
25
|
+
else
|
|
26
|
+
read_next
|
|
27
|
+
end
|
|
28
|
+
return result unless result == :retry
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# 按声明组、会话、模式的顺序寻找首个匹配,不按文本中的出现位置重新排序。
|
|
35
|
+
def find_match
|
|
36
|
+
@patterns.groups.each do |sessions, patterns|
|
|
37
|
+
sessions.each do |session|
|
|
38
|
+
next if @handled_eof.include?(session)
|
|
39
|
+
|
|
40
|
+
buffer = session.buffer
|
|
41
|
+
patterns.each do |pattern|
|
|
42
|
+
position = pattern.locate(buffer)
|
|
43
|
+
return [session, pattern, position] if position
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# 先记录并消费匹配,再执行回调;回调可选择结束、重置期限或保留期限继续。
|
|
51
|
+
def handle_match(session, pattern, position)
|
|
52
|
+
result = session.__send__(:record_match, pattern, position)
|
|
53
|
+
action = pattern.call(session)
|
|
54
|
+
return result unless continuing?(action)
|
|
55
|
+
|
|
56
|
+
@deadline = next_deadline if action == CONTINUE
|
|
57
|
+
# 零宽匹配可能不消费字节,必须在继续回调后检查期限,避免只匹配缓冲而永久空转。
|
|
58
|
+
return handle_timeout if action == CONTINUE_WITHOUT_RESET && expired?
|
|
59
|
+
|
|
60
|
+
:retry
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# 找出尚未派发 EOF 事件的会话,保证每个源只处理一次结束事件。
|
|
64
|
+
def unhandled_eof
|
|
65
|
+
@sessions.find { |session| session.eof? && !@handled_eof.include?(session) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# 将剩余字节交给 EOF 回调;需要继续时等待其他源,全部结束则立即返回。
|
|
69
|
+
def handle_eof(session)
|
|
70
|
+
result = session.__send__(:record_eof)
|
|
71
|
+
@handled_eof << session
|
|
72
|
+
actions = @patterns.eof_patterns_for(session).map { |pattern| pattern.call(session) }
|
|
73
|
+
return result unless actions.any? { |action| continuing?(action) }
|
|
74
|
+
|
|
75
|
+
@deadline = next_deadline if actions.include?(CONTINUE)
|
|
76
|
+
@sessions.all? { |candidate| @handled_eof.include?(candidate) } ? result : :retry
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# 在剩余期限内等待可读 IO;零超时仍允许首次非阻塞轮询,EINTR 重试不重新计时。
|
|
80
|
+
def read_next
|
|
81
|
+
return handle_timeout if @polled && expired?
|
|
82
|
+
|
|
83
|
+
readers = active_sessions
|
|
84
|
+
begin
|
|
85
|
+
# 先标记已轮询;即使 select 连续被信号中断,下一轮也会检查原期限。
|
|
86
|
+
@polled = true
|
|
87
|
+
ready = IO.select(readers.map(&:to_io), nil, nil, remaining)
|
|
88
|
+
rescue Errno::EINTR
|
|
89
|
+
return :retry
|
|
90
|
+
rescue IOError, SystemCallError => error
|
|
91
|
+
return record_error(error)
|
|
92
|
+
end
|
|
93
|
+
return handle_timeout unless ready
|
|
94
|
+
|
|
95
|
+
read_ready(ready.first, readers)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# 每个就绪 IO 读取一次,将异常归属到对应会话;仅显式启用时按接收数据刷新期限。
|
|
99
|
+
def read_ready(readable, sessions)
|
|
100
|
+
readable.each do |io|
|
|
101
|
+
session = sessions.find { |candidate| candidate.to_io.equal?(io) }
|
|
102
|
+
begin
|
|
103
|
+
data = session.__send__(:read_available)
|
|
104
|
+
rescue Errno::EINTR
|
|
105
|
+
next
|
|
106
|
+
rescue IOError, SystemCallError => error
|
|
107
|
+
return session.__send__(:record_error, error)
|
|
108
|
+
end
|
|
109
|
+
@deadline = next_deadline if data && session.reset_timeout_on_read?
|
|
110
|
+
end
|
|
111
|
+
:retry
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# 返回本次仍需监听的会话,供读取选择和超时回调使用。
|
|
115
|
+
def active_sessions = @sessions.reject { |session| @handled_eof.include?(session) }
|
|
116
|
+
# 只有约定的继续符号会驱动下一轮,普通回调返回值不会改变等待流程。
|
|
117
|
+
def continuing?(action) = [CONTINUE, CONTINUE_WITHOUT_RESET].include?(action)
|
|
118
|
+
# 使用单调时钟计算期限;nil 一直表示无限等待,不受系统时间调整影响。
|
|
119
|
+
def next_deadline = @timeout && (Expect.monotonic + @timeout)
|
|
120
|
+
# 计算传给 select 的非负等待秒数,避免计时跨过边界时产生负数。
|
|
121
|
+
def remaining = @deadline && [@deadline - Expect.monotonic, 0].max
|
|
122
|
+
# 判断有限期限是否已到达;无限等待不会触发超时。
|
|
123
|
+
def expired? = @deadline && Expect.monotonic >= @deadline
|
|
124
|
+
|
|
125
|
+
# select 失败时无法归属单个源,为本次会话记录同一原始异常并返回首个结果。
|
|
126
|
+
def record_error(error)
|
|
127
|
+
@sessions.map { |session| session.__send__(:record_error, error) }.first
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# 为活跃会话记录超时,回调接收全部活跃源;只有重置计时的继续符号能重新等待。
|
|
131
|
+
def handle_timeout
|
|
132
|
+
results = active_sessions.map { |session| session.__send__(:record_error, :timeout) }
|
|
133
|
+
action = @patterns.timeout_pattern&.call(active_sessions)
|
|
134
|
+
return results.first unless action == CONTINUE
|
|
135
|
+
|
|
136
|
+
@deadline = next_deadline
|
|
137
|
+
@polled = false
|
|
138
|
+
:retry
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# 为会话补充人工接管和多路 IO 转接;核心会话定义位于 lib/expect.rb。
|
|
4
|
+
class Expect
|
|
5
|
+
# 注册字面、正则转义或 :eof 事件;回调用闭包保存上下文,nil/false 停止,其余值继续。
|
|
6
|
+
def on_sequence(sequence, &block)
|
|
7
|
+
key = case sequence
|
|
8
|
+
when :eof, Regexp then sequence
|
|
9
|
+
when String then sequence.b.freeze
|
|
10
|
+
else raise ArgumentError, "sequence must be a String, Regexp or :eof"
|
|
11
|
+
end
|
|
12
|
+
raise ArgumentError, "escape sequence must not be empty" if key == ""
|
|
13
|
+
|
|
14
|
+
@sequences[key] = block
|
|
15
|
+
self
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# 临时将输入、会话和输出相连,实现人工接管;结束时恢复双方监听器、日志开关和转义设置。
|
|
19
|
+
def interact(input: $stdin, escape: nil, output: nil, timeout: nil)
|
|
20
|
+
source = input.is_a?(Expect) ? input : Expect.open(input)
|
|
21
|
+
output ||= input.equal?($stdin) ? $stdout : input
|
|
22
|
+
saved_self = [listeners, log_stdout, log_listeners]
|
|
23
|
+
saved_source = [source.listeners, source.log_stdout, source.log_listeners, source.sequences.dup]
|
|
24
|
+
# 临时建立“用户输入 -> 子进程 -> 显示输出”的双向连接,原监听关系在 ensure 中恢复。
|
|
25
|
+
self.listeners = [output]
|
|
26
|
+
self.log_stdout = false
|
|
27
|
+
self.log_listeners = true
|
|
28
|
+
source.listeners = [self]
|
|
29
|
+
source.log_stdout = false
|
|
30
|
+
source.log_listeners = true
|
|
31
|
+
source.on_sequence(escape) if escape
|
|
32
|
+
Expect.interconnect(self, source, timeout: timeout)
|
|
33
|
+
ensure
|
|
34
|
+
if saved_self
|
|
35
|
+
self.listeners, self.log_stdout, self.log_listeners = saved_self
|
|
36
|
+
source.listeners, source.log_stdout, source.log_listeners, source.sequences = saved_source
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# 按各会话 listeners 建立转发图,处理转义、EOF 和总期限,返回引发停止的会话或 nil。
|
|
41
|
+
def self.interconnect(*sessions, timeout: nil)
|
|
42
|
+
raise ArgumentError, "interconnect requires Expect sessions" if sessions.empty? || sessions.any? do |session|
|
|
43
|
+
!session.is_a?(Expect)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
period = duration(timeout)
|
|
47
|
+
deadline = period && (monotonic + period)
|
|
48
|
+
active = sessions.uniq
|
|
49
|
+
# buffers 保存尚未转发的字节,histories 保存正则跨读取所需的已转发前缀。
|
|
50
|
+
# 接管原匹配缓冲时只转发,日志已在实际读取时记录,不能重复写入。
|
|
51
|
+
buffers = active.to_h { |session| [session, session.clear_buffer] }
|
|
52
|
+
histories = active.to_h { |session| [session, "".b] }
|
|
53
|
+
terminal_objects = active.flat_map { |session| [session, *session.listeners] }.uniq
|
|
54
|
+
saved = []
|
|
55
|
+
polled = false
|
|
56
|
+
begin
|
|
57
|
+
terminal_objects.each do |object|
|
|
58
|
+
next if object.is_a?(Expect) && !object.raw_terminal?
|
|
59
|
+
|
|
60
|
+
io = object.respond_to?(:to_io) ? object.to_io : object
|
|
61
|
+
next unless io.respond_to?(:tty?) && io.tty?
|
|
62
|
+
next if saved.any? { |entry| entry.first.equal?(io) }
|
|
63
|
+
|
|
64
|
+
# 修改前先保存模式,同一个 IO 对象只保存一次,部分初始化失败也能恢复。
|
|
65
|
+
saved << [io, io.console_mode]
|
|
66
|
+
io.raw!
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
loop do
|
|
70
|
+
active.dup.each do |session|
|
|
71
|
+
return session unless relay_buffer(session, buffers, histories, final: session.eof?)
|
|
72
|
+
next unless session.eof?
|
|
73
|
+
|
|
74
|
+
# EOF 回调为真才继续监听其他源,当前结束源随后移出 active。
|
|
75
|
+
callback = session.__send__(:sequences)[:eof]
|
|
76
|
+
return session unless callback&.call
|
|
77
|
+
|
|
78
|
+
active.delete(session)
|
|
79
|
+
end
|
|
80
|
+
return nil if active.empty?
|
|
81
|
+
|
|
82
|
+
remaining = deadline && [deadline - monotonic, 0].max
|
|
83
|
+
ready = polled && remaining&.zero? ? nil : IO.select(active.map(&:to_io), nil, nil, remaining)
|
|
84
|
+
polled = true
|
|
85
|
+
unless ready
|
|
86
|
+
# 到期后不会再等转义的后半段,将暂存的字面前缀作为普通输入转发。
|
|
87
|
+
buffers.each do |session, buffer|
|
|
88
|
+
session.__send__(:propagate, buffer) unless buffer.empty?
|
|
89
|
+
buffer.clear
|
|
90
|
+
end
|
|
91
|
+
return nil
|
|
92
|
+
end
|
|
93
|
+
ready[0].each do |io|
|
|
94
|
+
session = active.find { |candidate| candidate.to_io.equal?(io) }
|
|
95
|
+
data = session.__send__(:read_available, propagate: false, accumulate: false)
|
|
96
|
+
buffers[session] << data if data
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
ensure
|
|
100
|
+
# 转义后的尾部归下次 expect/interact 使用;无论正常返回还是异常,都归还缓冲并恢复终端。
|
|
101
|
+
buffers.each { |session, buffer| session.buffer = buffer + session.buffer }
|
|
102
|
+
saved.reverse_each { |io, mode| io.console_mode = mode unless io.closed? }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# 转发一个会话的待处理缓冲并剔除转义;返回 false 表示应结束整个转接。
|
|
107
|
+
# 字面序列暂存潜在前缀,正则序列结合历史匹配;final 为真时不再等待后续字节。
|
|
108
|
+
def self.relay_buffer(session, buffers, histories, final: false)
|
|
109
|
+
buffer = buffers.fetch(session)
|
|
110
|
+
history = histories.fetch(session)
|
|
111
|
+
sequences = session.__send__(:sequences).except(:eof)
|
|
112
|
+
loop do
|
|
113
|
+
matches = sequences.filter_map do |key, handler|
|
|
114
|
+
if key.is_a?(Regexp)
|
|
115
|
+
position = Pattern.new(value: key).locate(history + buffer)
|
|
116
|
+
if position
|
|
117
|
+
offset, length, = position
|
|
118
|
+
raise ArgumentError, "escape regexp must consume at least one byte" if length.zero?
|
|
119
|
+
|
|
120
|
+
# 历史前缀已实时转发,不能撤回;负偏移表示本次只需消费转义尚未转发的部分。
|
|
121
|
+
[offset - history.bytesize, length, handler]
|
|
122
|
+
end
|
|
123
|
+
else
|
|
124
|
+
position = buffer.index(key)
|
|
125
|
+
[position, key.bytesize, handler] if position
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
if (found = matches.min_by(&:first))
|
|
129
|
+
position, length, callback = found
|
|
130
|
+
session.__send__(:propagate, buffer.byteslice(0, position)) if position.positive?
|
|
131
|
+
buffer.replace(buffer.byteslice([position + length, 0].max..))
|
|
132
|
+
# 转义消费后清除历史,防止继续回调再次匹配同一个转义。
|
|
133
|
+
history.clear
|
|
134
|
+
return false unless callback&.call
|
|
135
|
+
|
|
136
|
+
next
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# 暂存可能构成字面转义的最长后缀,保证 STOP 分两次读取时 ST 不会提前发给子进程。
|
|
140
|
+
held = 0
|
|
141
|
+
unless final
|
|
142
|
+
sequences.each_key do |key|
|
|
143
|
+
next if key.is_a?(Regexp)
|
|
144
|
+
|
|
145
|
+
[key.bytesize - 1, buffer.bytesize].min.downto(1) do |length|
|
|
146
|
+
if buffer.end_with?(key.byteslice(0, length))
|
|
147
|
+
held = [held, length].max
|
|
148
|
+
break
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
count = buffer.bytesize - held
|
|
154
|
+
session.__send__(:propagate, buffer.byteslice(0, count)) if count.positive?
|
|
155
|
+
if sequences.keys.any?(Regexp)
|
|
156
|
+
history << buffer.byteslice(0, count)
|
|
157
|
+
limit = session.buffer_limit
|
|
158
|
+
history.replace(history.byteslice(-limit, limit)) if limit && history.bytesize > limit
|
|
159
|
+
end
|
|
160
|
+
buffer.replace(held.zero? ? "".b : buffer.byteslice(-held, held))
|
|
161
|
+
return true
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
private_class_method :relay_buffer
|
|
165
|
+
|
|
166
|
+
protected
|
|
167
|
+
|
|
168
|
+
# 仅供转接内部保存和恢复注册表,避免公开可变 Hash 绕过 on_sequence 的校验。
|
|
169
|
+
attr_accessor :sequences
|
|
170
|
+
end
|