expect-pty 0.5.3 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +28 -19
- data/docs/API.md +88 -0
- data/docs/MIGRATION.md +15 -0
- data/lib/expect/cleanup.rb +33 -0
- data/lib/expect/configuration.rb +87 -66
- data/lib/expect/interaction.rb +226 -209
- data/lib/expect/logging.rb +157 -156
- data/lib/expect/matcher.rb +30 -29
- data/lib/expect/pattern.rb +8 -2
- data/lib/expect/pattern_list.rb +35 -5
- data/lib/expect/redactor.rb +11 -4
- data/lib/expect/relay.rb +97 -73
- data/lib/expect/relay_writer.rb +2 -1
- data/lib/expect/result.rb +32 -8
- data/lib/expect/session.rb +508 -0
- data/lib/expect/session_resources.rb +10 -2
- data/lib/expect/terminal.rb +78 -78
- data/lib/expect/version.rb +1 -1
- data/lib/expect.rb +333 -520
- data/sig/expect.rbs +355 -0
- metadata +13 -106
- data/.rubocop.yml +0 -66
- data/CONTRIBUTING.md +0 -29
- data/Gemfile +0 -28
- data/Rakefile +0 -38
- data/benchmark/matching.rb +0 -98
- data/benchmark/redactor.rb +0 -64
- data/benchmark/relay.rb +0 -48
- data/benchmark/scaling.rb +0 -70
- data/benchmark/send_slow.rb +0 -41
- data/benchmark/support.rb +0 -114
- data/docs/COMPATIBILITY.md +0 -98
- data/docs/INTERNAL_CONTRACTS.md +0 -154
- data/docs/PERFORMANCE.md +0 -165
- data/docs/RELEASING.md +0 -84
- data/docs/VERIFICATION.md +0 -456
- data/examples/dialogue.rb +0 -30
- data/examples/kibitz/README.md +0 -81
- data/examples/kibitz/kibitz.rb +0 -142
- data/examples/kibitz/test_kibitz.rb +0 -37
- data/examples/ssh_auto.rb +0 -94
- data/examples/ssh_interact.rb +0 -159
- data/examples/ssh_login.rb +0 -64
- data/expect-pty.gemspec +0 -33
- data/script/ci +0 -121
- data/script/release.rb +0 -319
- data/test/buffer_accounting_test.rb +0 -83
- data/test/cleanup_test.rb +0 -251
- data/test/compare_upstream.rb +0 -157
- data/test/configuration_test.rb +0 -133
- data/test/deadline_test.rb +0 -237
- data/test/diagnostics_test.rb +0 -353
- data/test/edge_case_test.rb +0 -262
- data/test/fixtures/ssh_scripts/01_identity.sh +0 -4
- data/test/fixtures/ssh_scripts/02_output.sh +0 -5
- data/test/fixtures/ssh_scripts/03_delayed.sh +0 -6
- data/test/fixtures/ssh_scripts/04_failure.sh +0 -2
- data/test/fixtures/ssh_scripts/05_recovery.sh +0 -3
- data/test/initialization_failure_test.rb +0 -92
- data/test/integration/README.md +0 -109
- data/test/integration/ssh_scripts.rb +0 -94
- data/test/interact_test.rb +0 -253
- data/test/interconnect_test.rb +0 -425
- data/test/io_test.rb +0 -321
- data/test/kibitz_test.rb +0 -45
- data/test/lifecycle_contract_test.rb +0 -71
- data/test/literal_scan_test.rb +0 -97
- data/test/matching_test.rb +0 -211
- data/test/multi_session_test.rb +0 -66
- data/test/ownership_sequence_test.rb +0 -208
- data/test/pattern_offset_test.rb +0 -43
- data/test/process_interruption_test.rb +0 -296
- data/test/process_test.rb +0 -291
- data/test/redactor_test.rb +0 -183
- data/test/relay_recovery_test.rb +0 -451
- data/test/relay_reentrancy_test.rb +0 -159
- data/test/release_test.rb +0 -375
- data/test/ruby_api_test.rb +0 -515
- data/test/scan_reuse_test.rb +0 -109
- data/test/script_logging_test.rb +0 -124
- data/test/support/interact_probe.rb +0 -125
- data/test/support/kibitz_probe.rb +0 -177
- data/test/support/script_probe.rb +0 -158
- data/test/terminal_cleanup_test.rb +0 -349
- data/test/test_helper.rb +0 -58
- data/test/timeout_test.rb +0 -289
- data/test/write_contract_test.rb +0 -105
data/test/script_logging_test.rb
DELETED
|
@@ -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
|