From 8c8a25e69dc5b3ea79a1ef6d271402c595e88adc Mon Sep 17 00:00:00 2001 From: Oskar Eichler Date: Mon, 31 Aug 2026 00:46:05 +0200 Subject: [PATCH 1/2] Align remote file reads with IO length semantics --- lib/net/sftp/operations/file.rb | 13 ++++++++++++- test/test_file.rb | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/net/sftp/operations/file.rb b/lib/net/sftp/operations/file.rb index d031050..e5e7531 100644 --- a/lib/net/sftp/operations/file.rb +++ b/lib/net/sftp/operations/file.rb @@ -63,6 +63,15 @@ def eof? # # This will advance the file pointer (#pos). def read(n=nil) + unless n.nil? || n.is_a?(Integer) + converted = n.to_int if n.respond_to?(:to_int) + raise TypeError, "no implicit conversion of #{n.class} into Integer" unless converted.is_a?(Integer) + + n = converted + end + raise ArgumentError, "negative length #{n} given" if n && n < 0 + return "" if n == 0 + loop do break if n && @buffer.length >= n break unless fill @@ -74,6 +83,8 @@ def read(n=nil) result, @buffer = @buffer, "" end + return nil if n && result.empty? && @real_eof + @pos += result.length return result end @@ -183,7 +194,7 @@ def stat def fill data = sftp.read!(handle, @real_pos, 8192) - if data.nil? + if data.nil? || data.empty? @real_eof = true return false else diff --git a/test/test_file.rb b/test/test_file.rb index c731fc7..1b4b771 100644 --- a/test/test_file.rb +++ b/test/test_file.rb @@ -58,6 +58,40 @@ def test_read_with_argument_should_read_and_return_n_bytes_and_set_pos assert_equal 5, @file.pos end + def test_read_with_positive_length_at_eof_should_return_nil + @sftp.expects(:read!).returns(nil) + assert_nil @file.read(1) + assert_equal 0, @file.pos + end + + def test_read_with_zero_length_should_not_read_remotely + @sftp.expects(:read!).never + assert_equal "", @file.read(0) + end + + def test_read_with_negative_length_should_raise_argument_error + @sftp.expects(:read!).never + assert_raises(ArgumentError) { @file.read(-1) } + end + + def test_read_should_coerce_length_with_to_int + length = Object.new + length.define_singleton_method(:to_int) { 5 } + @sftp.expects(:read!).returns("hello world") + assert_equal "hello", @file.read(length) + end + + def test_read_should_reject_non_integer_lengths + @sftp.expects(:read!).never + assert_raises(TypeError) { @file.read("5") } + end + + def test_read_should_treat_empty_data_response_as_eof + @sftp.expects(:read!).once.returns("") + assert_nil @file.read(1) + assert @file.eof? + end + def test_read_after_pos_assignment_should_read_from_specified_position @sftp.expects(:read!).with("handle", 5, 8192).returns("hello world") @file.pos = 5 From edbd45a5bc979c4ecc40a361f6138cc9d91ddfb1 Mon Sep 17 00:00:00 2001 From: Oskar Eichler Date: Mon, 31 Aug 2026 16:53:20 +0700 Subject: [PATCH 2/2] Avoid repeated reads after EOF --- lib/net/sftp/operations/file.rb | 2 ++ test/test_file.rb | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/net/sftp/operations/file.rb b/lib/net/sftp/operations/file.rb index e5e7531..c6a1822 100644 --- a/lib/net/sftp/operations/file.rb +++ b/lib/net/sftp/operations/file.rb @@ -192,6 +192,8 @@ def stat # Fills the buffer. Returns +true+ if it succeeded, and +false+ if # EOF was encountered before any data was read. def fill + return false if @real_eof + data = sftp.read!(handle, @real_pos, 8192) if data.nil? || data.empty? diff --git a/test/test_file.rb b/test/test_file.rb index 1b4b771..cec535e 100644 --- a/test/test_file.rb +++ b/test/test_file.rb @@ -59,7 +59,8 @@ def test_read_with_argument_should_read_and_return_n_bytes_and_set_pos end def test_read_with_positive_length_at_eof_should_return_nil - @sftp.expects(:read!).returns(nil) + @sftp.expects(:read!).once.returns(nil) + assert_nil @file.read(1) assert_nil @file.read(1) assert_equal 0, @file.pos end