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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +28 -19
  4. data/docs/API.md +88 -0
  5. data/docs/MIGRATION.md +15 -0
  6. data/lib/expect/cleanup.rb +33 -0
  7. data/lib/expect/configuration.rb +87 -66
  8. data/lib/expect/interaction.rb +226 -209
  9. data/lib/expect/logging.rb +157 -156
  10. data/lib/expect/matcher.rb +30 -29
  11. data/lib/expect/pattern.rb +8 -2
  12. data/lib/expect/pattern_list.rb +35 -5
  13. data/lib/expect/redactor.rb +11 -4
  14. data/lib/expect/relay.rb +97 -73
  15. data/lib/expect/relay_writer.rb +2 -1
  16. data/lib/expect/result.rb +32 -8
  17. data/lib/expect/session.rb +508 -0
  18. data/lib/expect/session_resources.rb +10 -2
  19. data/lib/expect/terminal.rb +78 -78
  20. data/lib/expect/version.rb +1 -1
  21. data/lib/expect.rb +333 -520
  22. data/sig/expect.rbs +355 -0
  23. metadata +13 -106
  24. data/.rubocop.yml +0 -66
  25. data/CONTRIBUTING.md +0 -29
  26. data/Gemfile +0 -28
  27. data/Rakefile +0 -38
  28. data/benchmark/matching.rb +0 -98
  29. data/benchmark/redactor.rb +0 -64
  30. data/benchmark/relay.rb +0 -48
  31. data/benchmark/scaling.rb +0 -70
  32. data/benchmark/send_slow.rb +0 -41
  33. data/benchmark/support.rb +0 -114
  34. data/docs/COMPATIBILITY.md +0 -98
  35. data/docs/INTERNAL_CONTRACTS.md +0 -154
  36. data/docs/PERFORMANCE.md +0 -165
  37. data/docs/RELEASING.md +0 -84
  38. data/docs/VERIFICATION.md +0 -456
  39. data/examples/dialogue.rb +0 -30
  40. data/examples/kibitz/README.md +0 -81
  41. data/examples/kibitz/kibitz.rb +0 -142
  42. data/examples/kibitz/test_kibitz.rb +0 -37
  43. data/examples/ssh_auto.rb +0 -94
  44. data/examples/ssh_interact.rb +0 -159
  45. data/examples/ssh_login.rb +0 -64
  46. data/expect-pty.gemspec +0 -33
  47. data/script/ci +0 -121
  48. data/script/release.rb +0 -319
  49. data/test/buffer_accounting_test.rb +0 -83
  50. data/test/cleanup_test.rb +0 -251
  51. data/test/compare_upstream.rb +0 -157
  52. data/test/configuration_test.rb +0 -133
  53. data/test/deadline_test.rb +0 -237
  54. data/test/diagnostics_test.rb +0 -353
  55. data/test/edge_case_test.rb +0 -262
  56. data/test/fixtures/ssh_scripts/01_identity.sh +0 -4
  57. data/test/fixtures/ssh_scripts/02_output.sh +0 -5
  58. data/test/fixtures/ssh_scripts/03_delayed.sh +0 -6
  59. data/test/fixtures/ssh_scripts/04_failure.sh +0 -2
  60. data/test/fixtures/ssh_scripts/05_recovery.sh +0 -3
  61. data/test/initialization_failure_test.rb +0 -92
  62. data/test/integration/README.md +0 -109
  63. data/test/integration/ssh_scripts.rb +0 -94
  64. data/test/interact_test.rb +0 -253
  65. data/test/interconnect_test.rb +0 -425
  66. data/test/io_test.rb +0 -321
  67. data/test/kibitz_test.rb +0 -45
  68. data/test/lifecycle_contract_test.rb +0 -71
  69. data/test/literal_scan_test.rb +0 -97
  70. data/test/matching_test.rb +0 -211
  71. data/test/multi_session_test.rb +0 -66
  72. data/test/ownership_sequence_test.rb +0 -208
  73. data/test/pattern_offset_test.rb +0 -43
  74. data/test/process_interruption_test.rb +0 -296
  75. data/test/process_test.rb +0 -291
  76. data/test/redactor_test.rb +0 -183
  77. data/test/relay_recovery_test.rb +0 -451
  78. data/test/relay_reentrancy_test.rb +0 -159
  79. data/test/release_test.rb +0 -375
  80. data/test/ruby_api_test.rb +0 -515
  81. data/test/scan_reuse_test.rb +0 -109
  82. data/test/script_logging_test.rb +0 -124
  83. data/test/support/interact_probe.rb +0 -125
  84. data/test/support/kibitz_probe.rb +0 -177
  85. data/test/support/script_probe.rb +0 -158
  86. data/test/terminal_cleanup_test.rb +0 -349
  87. data/test/test_helper.rb +0 -58
  88. data/test/timeout_test.rb +0 -289
  89. data/test/write_contract_test.rb +0 -105
@@ -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
@@ -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"
@@ -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
- )