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,124 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "test_helper"
4
- require_relative "support/script_probe"
5
- require "etc"
6
-
7
- class ScriptLoggingTest < ExpectTest
8
- def shell_probe
9
- session = Expect.spawn("/bin/sh", "-i", env: { "PS1" => ScriptProbe::PROMPT, "ENV" => nil, "LC_ALL" => "C" },
10
- raw_pty: true, log_stdout: false, write_timeout: 3)
11
- @sessions << session
12
- ScriptProbe::Runner.new(session).ready!
13
- end
14
-
15
- def test_multiple_script_files_with_persisted_log_and_shutdown_drain
16
- runner = shell_probe
17
- Dir.mktmpdir do |dir|
18
- report = ScriptProbe.verify_file_session(runner, File.join(dir, "session.log"), user: Etc.getpwuid.name)
19
- assert_equal([0, 0, 0, 7, 0, 0, 0], report[:cases].map { |result| result[:status] })
20
- assert(report[:cases].all? { |result| result[:passed] })
21
- assert_operator report[:log_bytes], :>, 500
22
- assert_equal 10, report[:checks].length
23
- end
24
- end
25
-
26
- def test_callback_log_records_multiple_scripts_exactly_once
27
- runner = shell_probe
28
- chunks = []
29
- runner.session.log_to(->(bytes) { chunks << bytes.dup })
30
- %w[first second].each do |value|
31
- runner.run(value, "printf '#{value}\\n'\n", expected_status: 0, expected_output: "#{value}\n")
32
- end
33
- runner.finish!
34
- log = ScriptProbe.normalize(chunks.join)
35
- assert_equal 1, log.scan(/^first$/).length
36
- assert_equal 1, log.scan(/^second$/).length
37
- assert_operator log.index("[SEND] first"), :<, log.index("[SEND] second")
38
- assert_includes log, "SESSION_FINAL_TAIL\n"
39
- end
40
-
41
- def test_borrowed_log_is_flushed_and_stays_open_after_multiple_scripts
42
- runner = shell_probe
43
- Tempfile.create("expect-borrowed-log") do |file|
44
- runner.session.log_to(file)
45
- runner.run("one", "printf 'BORROWED_ONE\\n'", expected_status: 0, expected_output: "BORROWED_ONE\n")
46
- assert_includes File.binread(file.path), "BORROWED_ONE\n"
47
- runner.run("two", "printf 'BORROWED_TWO\\n'", expected_status: 0, expected_output: "BORROWED_TWO\n")
48
- runner.finish!
49
- refute file.closed?
50
- text = File.binread(file.path)
51
- assert_includes text, "BORROWED_TWO\n"
52
- assert_includes text, "SESSION_FINAL_TAIL\n"
53
- end
54
- end
55
-
56
- def test_nonzero_exit_is_reported_even_when_expected_text_appears
57
- runner = shell_probe
58
- failure = assert_raises(ScriptProbe::Failure) do
59
- runner.run("bad_status", "printf 'looks good\\n'; exit 9", expected_status: 0, expected_output: "looks good\n")
60
- end
61
- assert_match(/exit 9, expected 0/, failure.message)
62
- refute runner.results.last[:passed]
63
- result = runner.run("after_failure", "printf 'still alive\\n'", expected_status: 0,
64
- expected_output: "still alive\n")
65
- assert result[:passed]
66
- end
67
-
68
- def test_matching_output_is_required_even_with_zero_exit_status
69
- runner = shell_probe
70
- assert_raises(ScriptProbe::Failure) do
71
- runner.run("wrong_output", "printf 'actual\\n'", expected_status: 0, expected_output: "expected\n")
72
- end
73
- refute runner.results.last[:passed]
74
- end
75
-
76
- def test_script_arguments_preserve_quotes_and_shell_metacharacters
77
- runner = shell_probe
78
- result = runner.run("quoting", "printf '%s\\n' \"a'b\" '$(not-a-command)' 'semi;colon'\n",
79
- expected_status: 0, expected_output: "a'b\n$(not-a-command)\nsemi;colon\n")
80
- assert result[:passed]
81
- end
82
-
83
- def test_crlf_terminal_output_preserves_exact_script_boundaries
84
- runner = shell_probe
85
- runner.session.write("stty opost onlcr\n")
86
- assert_equal 1, runner.session.expect(ScriptProbe::PROMPT, timeout: 2)
87
- ["", "line\n", "no final newline", "two\n\n"].each do |output|
88
- result = runner.run("crlf", "printf %s #{Shellwords.escape(output)}",
89
- expected_status: 0, expected_output: output)
90
- assert_equal output, result[:output]
91
- end
92
- end
93
-
94
- def test_logger_error_is_visible_to_caller
95
- runner = shell_probe
96
- runner.session.log_to(->(_) { raise IOError, "log destination failed" })
97
- error = assert_raises(IOError) do
98
- runner.run("logger_failure", "printf 'output\\n'", expected_status: 0, expected_output: "output\n")
99
- end
100
- assert_equal "log destination failed", error.message
101
- assert_empty runner.results
102
- end
103
-
104
- def test_timeout_preserves_log_and_subsequent_output_is_captured
105
- runner = shell_probe
106
- session = runner.session
107
- Dir.mktmpdir do |dir|
108
- path = File.join(dir, "timeout.log")
109
- session.log_to(path, mode: "w")
110
- session.write("printf 'BEFORE_TIMEOUT\\n'; sleep 0.15; printf 'AFTER_TIMEOUT\\n'\n")
111
- assert_equal 1, session.expect("BEFORE_TIMEOUT\n", timeout: 2)
112
- assert_nil session.expect("AFTER_TIMEOUT", timeout: 0.02)
113
- assert_equal :timeout, session.error
114
- assert_includes File.binread(path), "BEFORE_TIMEOUT\n"
115
- assert_equal 1, session.expect("AFTER_TIMEOUT\n", timeout: 2)
116
- assert_equal 1, session.expect(ScriptProbe::PROMPT, timeout: 2)
117
- runner.finish!
118
- text = File.binread(path)
119
- assert_equal 1, text.scan("BEFORE_TIMEOUT\n").length
120
- assert_equal 1, text.scan("AFTER_TIMEOUT\n").length
121
- assert_includes text, "SESSION_FINAL_TAIL\n"
122
- end
123
- end
124
- end
@@ -1,125 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "script_probe"
4
-
5
- module InteractProbe
6
- ESCAPE = "\x1d".b.freeze
7
- TAIL = "LOCAL_ONLY_TAIL".b.freeze
8
-
9
- def self.configuration(session)
10
- state = session.stty
11
- return state unless RUBY_PLATFORM.include?("darwin")
12
-
13
- # PENDIN is a transient Darwin kernel state, not a termios configuration.
14
- state.sub(/lflag=([0-9a-f]+)/) { "lflag=#{(Regexp.last_match(1).to_i(16) & ~0x20000000).to_s(16)}" }
15
- end
16
-
17
- def self.settings(session)
18
- [session.listeners, session.log_stdout, session.log_listeners, session.raw_terminal?,
19
- session.instance_variable_get(:@sequences).dup]
20
- end
21
-
22
- def self.prepare(session, echo: false)
23
- # The remote tty needs ISIG for Ctrl-C to reach its foreground process.
24
- # Manual interaction needs remote echo because the local input is raw.
25
- session.write("stty sane #{echo ? "echo" : "-echo"}; set +o emacs; set +o vi\n")
26
- ScriptProbe.check(session.expect(ScriptProbe::PROMPT, timeout: 5), "terminal setup did not return to shell")
27
- session.clear_buffer
28
- end
29
-
30
- # A real local terminal slave is passed to interact; its master acts as a
31
- # keyboard/screen. Only interact reads the remote session during handoff.
32
- def self.verify(session, user:)
33
- master, slave = PTY.open
34
- source = Expect.open(slave)
35
- screen = Expect.open(master, write_timeout: 3)
36
- source.on_sequence("ORIGINAL_ESCAPE") { false }
37
- source.log_listeners = false
38
- source.log_stdout = false
39
- terminal_state = configuration(source)
40
- remote_state = configuration(session)
41
- source_settings = settings(source)
42
- remote_settings = settings(session)
43
- results = []
44
- checks = []
45
-
46
- run_cycle = lambda do |number, &actions|
47
- nonce = SecureRandom.hex(8)
48
- marker = "HANDOFF_#{nonce}"
49
- session.write("printf '\\n%s%s\\n' 'HANDOFF_' '#{nonce}'\n")
50
- worker = Thread.new do
51
- ScriptProbe.check(screen.expect(/#{Regexp.escape(marker)}\r?\n/, timeout: 5),
52
- "handoff output did not reach local terminal")
53
- ScriptProbe.check(screen.expect(ScriptProbe::PROMPT, timeout: 5), "handoff prompt missing")
54
- ScriptProbe.check(!slave.echo?, "local input terminal did not disable echo")
55
- runner = ScriptProbe::Runner.new(screen)
56
- actions.call(runner)
57
- screen.write(ESCAPE + TAIL)
58
- runner.results
59
- rescue Exception # rubocop:disable Lint/RescueException -- Clean up terminal drivers even on Interrupt or SystemExit.
60
- begin
61
- screen.write(ESCAPE)
62
- rescue StandardError
63
- nil
64
- end
65
- raise
66
- end
67
- worker.report_on_exception = false
68
- begin
69
- returned = session.interact(input: source, escape: ESCAPE, output: slave, timeout: 20)
70
- ScriptProbe.check(worker.join(2), "keyboard driver did not finish")
71
- results.concat(worker.value)
72
- ScriptProbe.check(returned.equal?(source), "interact did not return on the local escape")
73
- ScriptProbe.check(source.clear_buffer == TAIL, "escape tail was lost or forwarded to remote")
74
- ScriptProbe.check(session.alive?, "local escape closed the remote process")
75
- ScriptProbe.check(configuration(source) == terminal_state, "local terminal settings were not restored")
76
- ScriptProbe.check(configuration(session) == remote_state,
77
- "remote transport terminal settings were not restored")
78
- ScriptProbe.check(settings(source) == source_settings, "local groups/flags/escape handlers were not restored")
79
- ScriptProbe.check(settings(session) == remote_settings, "remote groups/flags/escape handlers were not restored")
80
- checks << "cycle_#{number}_escape_tail_and_restore"
81
- ensure
82
- worker.kill.join if worker.alive?
83
- end
84
- end
85
-
86
- run_cycle.call(1) do |runner|
87
- runner.run("interactive_identity", "printf 'USER='; id -un; test -t 0 && printf 'TTY_OK\\n'",
88
- expected_status: 0, expected_output: "USER=#{user}\nTTY_OK\n")
89
- runner.run_file(
90
- File.join(ScriptProbe::FIXTURES, "02_output.sh"),
91
- expected_status: 0,
92
- expected_output: "STDOUT_FIRST\nSTDERR_SECOND\n中文输出:日志验证\nSTDOUT_LAST\n"
93
- )
94
- nonce = SecureRandom.hex(8)
95
- # An actual foreground shell handles the forwarded Ctrl-C. Readiness is
96
- # acknowledged before sending the control byte, without a timing guess.
97
- script = "trap 'printf \"INT_HANDLED_#{nonce}\\n\"; exit 0' INT; " \
98
- "printf 'INT_READY_#{nonce}\\n'; while :; do sleep 1; done"
99
- screen.write("/bin/sh -c #{Shellwords.escape(script)}\n")
100
- ScriptProbe.check(screen.expect(/INT_READY_#{nonce}\r?\n/, timeout: 5), "foreground command did not become ready")
101
- screen.write("\x03")
102
- ScriptProbe.check(screen.expect(/INT_HANDLED_#{nonce}\r?\n/, timeout: 5),
103
- "Ctrl-C did not reach remote foreground process")
104
- ScriptProbe.check(screen.expect(ScriptProbe::PROMPT, timeout: 5), "shell did not recover after Ctrl-C")
105
- checks << "ctrl_c_forwarded_to_remote"
106
- end
107
-
108
- resumed = ScriptProbe::Runner.new(session)
109
- resumed.run("expect_after_interact", "printf 'AUTOMATION_RESUMED\\n'", expected_status: 0,
110
- expected_output: "AUTOMATION_RESUMED\n")
111
- results.concat(resumed.results)
112
- run_cycle.call(2) do |runner|
113
- runner.run("interact_again", "printf 'SECOND_HANDOFF_OK\\n'", expected_status: 0,
114
- expected_output: "SECOND_HANDOFF_OK\n")
115
- end
116
- checks.push("keyboard_to_remote", "remote_to_screen", "utf8_stdout_stderr",
117
- "raw_mode_during_interact", "expect_resume", "reenter_interact")
118
- { cases: results, checks: checks,
119
- local_tty: slave.path, local_terminal_restored: true, transport_terminal_restored: true }
120
- ensure
121
- source&.close
122
- screen&.close
123
- [master, slave].compact.each { |io| io.close unless io.closed? }
124
- end
125
- end
@@ -1,177 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "interact_probe"
4
-
5
- # Drive the actual host and join CLIs through two independent local terminals.
6
- # No Minitest dependency, SSH service, password, or external user is needed.
7
- module KibitzProbe
8
- EXAMPLE = File.expand_path("../../examples/kibitz/kibitz.rb", __dir__)
9
-
10
- class Pair
11
- attr_reader :host, :guest, :socket_path, :log_path
12
-
13
- def initialize(directory, noproc: false, host_flags: [], guest_flags: [])
14
- @sessions = []
15
- @terminal_modes = {}
16
- @log_path = File.join(directory, "session.log")
17
- command = noproc ? ["--noproc"] : ["--", "env", "ENV=", "PS1=#{ScriptProbe::PROMPT}", "LC_ALL=C", "/bin/sh", "-i"]
18
- @host = spawn_cli("--timeout", "15", "--log", log_path, *host_flags, *command)
19
- invitation = host.expect_result(%r{--join (/tmp/expect-kibitz-[^\r\n ]+/peer\.sock)}, timeout: 5)
20
- ScriptProbe.check(invitation.matched?, "host did not print the join command")
21
- @socket_path = invitation.captures.first
22
- ScriptProbe.check(File.stat(File.dirname(socket_path)).mode & 0o777 == 0o700, "socket directory is not private")
23
- ScriptProbe.check(File.stat(socket_path).mode & 0o777 == 0o600, "socket is not private")
24
- @guest = spawn_cli("--join", socket_path, "--timeout", "15", *guest_flags)
25
- [host, guest].each { |terminal| expect(terminal, /Kibitz connected\.[^\r\n]*\r?\n/) }
26
- ScriptProbe.check(!host.to_io.echo? && !guest.to_io.echo?, "connected banner preceded terminal readiness")
27
- rescue Exception # rubocop:disable Lint/RescueException -- Clean up terminal drivers even on Interrupt or SystemExit.
28
- close
29
- raise
30
- end
31
-
32
- def spawn_cli(*)
33
- terminal = Expect.new(log_stdout: false, write_timeout: 3)
34
- @sessions << terminal
35
- @terminal_modes[terminal] = InteractProbe.configuration(terminal)
36
- terminal.spawn(RbConfig.ruby, EXAMPLE, *)
37
- terminal
38
- end
39
-
40
- def expect(terminal, pattern)
41
- result = terminal.expect_result(pattern, timeout: 5)
42
- ScriptProbe.check(result.matched?,
43
- "#{terminal.equal?(host) ? "host" : "guest"} missing #{pattern.inspect} (#{result.error})")
44
- result
45
- end
46
-
47
- def both(pattern)
48
- [host, guest].map { |terminal| expect(terminal, pattern) }
49
- end
50
-
51
- def prepare_shell
52
- ScriptProbe::Runner.new(host).ready!
53
- host.write("printf '\\n%s%s\\n' 'KIBITZ_' 'READY'\n")
54
- both(/\r?\nKIBITZ_READY\r?\n/)
55
- both(ScriptProbe::PROMPT)
56
- end
57
-
58
- def command(terminal, command, output)
59
- terminal.write(command, "\n")
60
- both(output)
61
- both(ScriptProbe::PROMPT)
62
- end
63
-
64
- def finish!(expected_host_status: 0)
65
- [host, guest].each do |terminal|
66
- result = terminal.expect_result(:eof, timeout: 5)
67
- ScriptProbe.check(result.eof?, "kibitz did not reach EOF")
68
- status = terminal.wait(timeout: 2)
69
- expected = terminal.equal?(host) ? expected_host_status : 0
70
- ScriptProbe.check(status && status.exitstatus == expected,
71
- "kibitz exit status was #{status&.exitstatus.inspect}, expected #{expected}")
72
- ScriptProbe.check(InteractProbe.configuration(terminal) == @terminal_modes.fetch(terminal),
73
- "terminal mode was not restored")
74
- end
75
- ScriptProbe.check(!File.exist?(File.dirname(socket_path)), "socket directory was not cleaned up")
76
- ScriptProbe.check(File.stat(log_path).mode & 0o777 == 0o600, "log permissions changed")
77
- self
78
- end
79
-
80
- def close
81
- @sessions&.reverse_each(&:close)
82
- end
83
- end
84
-
85
- def self.shared_shell(directory)
86
- pair = Pair.new(directory)
87
- # With local terminals raw, the process's terminal supplies typed echo.
88
- pair.both(ScriptProbe::PROMPT)
89
- pair.host.write("printf 'VISIBLE_TYPING\\n'")
90
- pair.both("printf 'VISIBLE_TYPING\\n'")
91
- pair.host.write("\n")
92
- pair.both(/\r?\nVISIBLE_TYPING\r?\n/)
93
- pair.both(ScriptProbe::PROMPT)
94
- # ready! expects an outstanding prompt; use a fresh one after the echo check.
95
- pair.host.write("\n")
96
- pair.prepare_shell
97
- pair.command(pair.host, "shared_value=42; printf 'HOST_SET\\n'", /HOST_SET\r?\n/)
98
- pair.command(pair.guest, "printf 'SHARED=%s\\n' \"$shared_value\"", /SHARED=42\r?\n/)
99
- pair.command(pair.guest, "printf '中文输出\\n'; printf 'GUEST_STDERR\\n' >&2", /中文输出\r?\nGUEST_STDERR\r?\n/)
100
- pair.command(pair.host, "/bin/sh -c 'exit 7'; printf 'RECOVERED=%s\\n' \"$?\"", /RECOVERED=7\r?\n/)
101
- script = "trap 'printf \"INT_HANDLED\\n\"; exit 0' INT; printf 'INT_READY\\n'; while :; do sleep 1; done"
102
- pair.host.write("/bin/sh -c #{Shellwords.escape(script)}\n")
103
- pair.both(/INT_READY\r?\n/)
104
- pair.guest.write("\x03")
105
- pair.both(/INT_HANDLED\r?\n/)
106
- pair.both(ScriptProbe::PROMPT)
107
- pair.guest.write("printf 'FINAL_TAIL\\n'; exit 0\n")
108
- pair.both(/FINAL_TAIL\r?\n/)
109
- pair.finish!
110
- log = ScriptProbe.normalize(File.binread(pair.log_path))
111
- %w[HOST_SET SHARED=42 GUEST_STDERR RECOVERED=7 INT_READY INT_HANDLED FINAL_TAIL].each do |line|
112
- # With echo disabled the output can follow a prompt on the same line.
113
- ScriptProbe.check(log.scan("#{line}\n").length == 1, "#{line} missing or duplicated in log")
114
- end
115
- { name: "shared_shell", passed: true,
116
- checks: %w[visible_typing both_keyboards shared_shell_state broadcast_stdout_stderr utf8 nonzero_recovery
117
- guest_ctrl_c process_eof final_tail unique_log terminal_restore socket_cleanup] }
118
- ensure
119
- pair&.close
120
- end
121
-
122
- def self.direct(directory, ending: :host)
123
- custom = ending == :host ? ["--escape", "STOP"] : []
124
- pair = Pair.new(directory, noproc: true, host_flags: custom)
125
- pair.host.write("HOST_MESSAGE")
126
- pair.expect(pair.guest, "HOST_MESSAGE")
127
- ScriptProbe.check(pair.host.expect("HOST_MESSAGE", timeout: 0.02).nil?, "noproc echoed the sender's input")
128
- pair.guest.write("中文回信")
129
- pair.expect(pair.host, "中文回信")
130
- ScriptProbe.check(pair.guest.expect("中文回信", timeout: 0.02).nil?, "noproc echoed the guest's input")
131
- if ending == :host
132
- pair.host.write("prefixST")
133
- pair.expect(pair.guest, "prefix")
134
- ScriptProbe.check(pair.guest.expect("ST", timeout: 0.02).nil?, "split escape prefix leaked to peer")
135
- pair.host.write("OPLOCAL_TAIL")
136
- else
137
- pair.guest.write(InteractProbe::ESCAPE)
138
- end
139
- pair.finish!
140
- ScriptProbe.check(!pair.guest.before.include?("LOCAL_TAIL") && !pair.guest.before.include?("STOP"),
141
- "local escape leaked to peer")
142
- { name: "noproc_#{ending}_escape", passed: true,
143
- checks: %w[two_way_bytes utf8 no_local_echo escape peer_eof terminal_restore socket_cleanup] }
144
- ensure
145
- pair&.close
146
- end
147
-
148
- def self.noescape(directory)
149
- pair = Pair.new(directory, noproc: true, host_flags: ["--noescape"])
150
- pair.host.write("before\x1dafter")
151
- pair.expect(pair.guest, "before\x1dafter")
152
- pair.guest.write(InteractProbe::ESCAPE)
153
- pair.finish!
154
- { name: "noescape", passed: true, checks: %w[control_byte_forwarded peer_eof terminal_restore socket_cleanup] }
155
- ensure
156
- pair&.close
157
- end
158
-
159
- def self.process_failure(directory)
160
- pair = Pair.new(directory)
161
- pair.prepare_shell
162
- pair.host.write("exit 7\n")
163
- pair.finish!(expected_host_status: 7)
164
- { name: "process_failure", passed: true, checks: %w[nonzero_exit_status peer_eof terminal_restore socket_cleanup] }
165
- ensure
166
- pair&.close
167
- end
168
-
169
- def self.relay_timeout(directory)
170
- pair = Pair.new(directory, noproc: true, host_flags: ["--timeout", "1"])
171
- pair.finish!(expected_host_status: 1)
172
- ScriptProbe.check(pair.host.before.include?("Kibitz ended (timeout)."), "relay did not report its timeout")
173
- { name: "relay_timeout", passed: true, checks: %w[timeout_status peer_eof terminal_restore socket_cleanup] }
174
- ensure
175
- pair&.close
176
- end
177
- end
@@ -1,158 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
- require "securerandom"
5
- require "shellwords"
6
- require_relative "../../lib/expect/pty"
7
-
8
- # Shared by the real SSH check and the automatic local-PTY tests. This is a
9
- # test harness for a POSIX shell, not a network-device command abstraction.
10
- module ScriptProbe
11
- PROMPT = "EXPECT_SCRIPT_PROMPT> "
12
- FIXTURES = File.expand_path("../fixtures/ssh_scripts", __dir__)
13
-
14
- class Failure < StandardError; end
15
-
16
- def self.check(condition, message)
17
- raise Failure, message unless condition
18
- end
19
-
20
- def self.normalize(bytes)
21
- bytes.gsub("\r\n", "\n").b
22
- end
23
-
24
- class Runner
25
- attr_reader :session, :results
26
-
27
- def initialize(session, timeout: 5)
28
- @session = session
29
- @timeout = timeout
30
- @results = []
31
- end
32
-
33
- def ready!
34
- result = session.expect_result(PROMPT, timeout: @timeout)
35
- ScriptProbe.check(result.matched?, "shell prompt missing (#{result.error})")
36
- # Avoid terminal echo being mistaken for script output or leaking the
37
- # test's wrapper command into the output we verify.
38
- # Interactive shells may interpret high-bit bytes as readline commands
39
- # in a C locale. Disable editing for literal script transmission.
40
- session.write("stty -echo; set +o emacs; set +o vi\n")
41
- result = session.expect_result(PROMPT, timeout: @timeout)
42
- ScriptProbe.check(result.matched?, "shell setup failed (#{result.error})")
43
- session.clear_buffer
44
- self
45
- end
46
-
47
- def run_file(path, expected_status:, expected_output:)
48
- run(File.basename(path), File.binread(path), expected_status: expected_status, expected_output: expected_output)
49
- end
50
-
51
- def run(name, script, expected_status:, expected_output:)
52
- nonce = SecureRandom.hex(12)
53
- start_marker = "PROBE_BEGIN_#{nonce}"
54
- end_marker = "PROBE_END_#{nonce}"
55
- digest = Digest::SHA256.hexdigest(script)
56
- # Quote once for the remote shell without inserting backslashes between
57
- # UTF-8 bytes (File.binread returns an ASCII-8BIT string).
58
- quoted_script = "'#{script.gsub("'", %q('"'"'))}'"
59
- # Inputs are logged deliberately as metadata: Expect's automatic log
60
- # records received bytes, not sends. Never include authentication here.
61
- session.write_log("\n[SEND] #{name} sha256=#{digest}\n")
62
- command = "printf '\\n%s%s\\n' 'PROBE_BEGIN_' '#{nonce}'; " \
63
- "/bin/sh -c #{quoted_script}; probe_status=$?; " \
64
- "printf '\\n%s%s:%s\\n' 'PROBE_END_' '#{nonce}' \"$probe_status\"\n"
65
- session.write(command)
66
- started = session.expect_result(/(?:\A|\r?\n)#{Regexp.escape(start_marker)}\r?\n/, timeout: @timeout)
67
- ScriptProbe.check(started.matched?, "#{name}: begin marker missing (#{started.error})")
68
- ended = session.expect_result(/(?:\A|\r?\n)#{Regexp.escape(end_marker)}:(\d+)\r?\n/, timeout: @timeout)
69
- ScriptProbe.check(ended.matched?, "#{name}: script did not finish (#{ended.error})")
70
- output = ScriptProbe.normalize(ended.before)
71
- status = Integer(ended.captures.fetch(0), 10)
72
- # Wait for the shell before starting another script, including when the
73
- # prior script exited nonzero. Each script runs in its own subshell.
74
- prompt = session.expect_result(PROMPT, timeout: @timeout)
75
- ScriptProbe.check(prompt.matched?, "#{name}: shell did not recover (#{prompt.error})")
76
- session.write_log("\n[EXIT] #{name} status=#{status}\n")
77
- passed = status == expected_status && output == expected_output.b
78
- result = { name: name, sha256: digest, status: status, expected_status: expected_status,
79
- output: output.dup.force_encoding(Encoding::UTF_8), passed: passed }
80
- results << result
81
- ScriptProbe.check(status == expected_status, "#{name}: exit #{status}, expected #{expected_status}")
82
- ScriptProbe.check(output == expected_output.b, "#{name}: output differs from fixture expectation")
83
- result
84
- end
85
-
86
- def finish!
87
- # The last bytes arrive while soft_close drains the session. No expect
88
- # call is made after this send, so the log proves shutdown draining.
89
- session.write("sleep 0.1; printf '%s%s\\n' 'SESSION_' 'FINAL_TAIL'; exit 0\n")
90
- session.soft_close(timeout: 3)
91
- ScriptProbe.check(session.closed? && session.exit_code&.zero?, "shell did not exit cleanly")
92
- end
93
- end
94
-
95
- # Run all script fixtures in one session, then verify the persisted log.
96
- # An explicit nonzero fixture is an expected success of the test harness.
97
- def self.verify_file_session(runner, path, user:)
98
- session = runner.session
99
- # With SSH, the remote TTY differs from the local PTY. Obtain it using an
100
- # independent command, then demand an exact identity fixture response.
101
- session.write("printf '\\n%s' 'TTY_PROBE='; tty\n")
102
- tty = session.expect_result(%r{(?:\A|\n)TTY_PROBE=(/dev/[^\r\n]+)\r?\n}, timeout: 5)
103
- check(tty.matched?, "remote TTY probe failed")
104
- terminal = tty.captures.fetch(0)
105
- check(session.expect(PROMPT, timeout: 5), "TTY probe did not return to shell")
106
-
107
- # The caller allocates a fresh private directory; exercise truncation only
108
- # on this new test file, never on an existing user's log.
109
- File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |file| file.write("OLD_TEST_CONTENT\n") }
110
- log = session.log_to(path, mode: "w")
111
- cases = [
112
- ["01_identity.sh", 0, "USER=#{user}\nTTY=#{terminal}\nIDENTITY_OK\n"],
113
- ["02_output.sh", 0, "STDOUT_FIRST\nSTDERR_SECOND\n中文输出:日志验证\nSTDOUT_LAST\n"],
114
- ["03_delayed.sh", 0, "DELAY_BEGIN\nDELAY_MIDDLE\nDELAY_END\n"],
115
- ["04_failure.sh", 7, "EXPECTED_FAILURE\n"],
116
- ["05_recovery.sh", 0, "RECOVERY_OK\nVALUE=42\n"]
117
- ]
118
- cases.each do |name, status, output|
119
- runner.run_file(File.join(FIXTURES, name), expected_status: status, expected_output: output)
120
- # Read while the logger is open to prove writes are immediately visible.
121
- live = normalize(File.binread(path))
122
- check(live.include?(output.b), "#{name}: live log is missing output")
123
- check(live.include?("[EXIT] #{name} status=#{status}\n"), "#{name}: live exit annotation missing")
124
- end
125
- session.log_output = nil
126
- check(log.closed?, "owned log was not closed")
127
- before_disabled = File.binread(path)
128
- runner.run("logging_disabled", "printf 'UNLOGGED_OUTPUT\\n'\n", expected_status: 0,
129
- expected_output: "UNLOGGED_OUTPUT\n")
130
- check(File.binread(path) == before_disabled, "disabled logger still wrote data")
131
-
132
- # The default mode must append, retaining every preceding command.
133
- appended = session.log_to(path)
134
- runner.run("logging_resumed", "printf 'APPEND_OK\\n'\n", expected_status: 0, expected_output: "APPEND_OK\n")
135
- runner.finish!
136
- check(appended.closed?, "close did not close the owned log")
137
- bytes = File.binread(path)
138
- check(bytes.start_with?(before_disabled), "append mode overwrote previous log bytes")
139
- text = normalize(bytes)
140
- check(!text.include?("OLD_TEST_CONTENT") && !text.include?("UNLOGGED_OUTPUT"),
141
- "truncated or disabled data leaked into log")
142
- position = -1
143
- cases.each do |name, status, output|
144
- sent = text.index("[SEND] #{name} ")
145
- exited = text.index("[EXIT] #{name} status=#{status}\n")
146
- check(sent && exited && sent > position && exited > sent, "#{name}: incorrect log execution order")
147
- check(text.scan(Regexp.new(Regexp.escape(output.b), Regexp::NOENCODING)).length == 1,
148
- "#{name}: output missing or duplicated")
149
- position = exited
150
- end
151
- check(text.include?("APPEND_OK\n"), "resumed logging lost output")
152
- check(text.scan("SESSION_FINAL_TAIL\n").length == 1, "shutdown lost or duplicated final output")
153
- { cases: runner.results, remote_tty: terminal, log_bytes: bytes.bytesize,
154
- log_sha256: Digest::SHA256.hexdigest(bytes),
155
- checks: %w[exact_output execution_order live_flush stdout_stderr utf8 nonzero_recovery
156
- truncate disable append eof_drain] }
157
- end
158
- end