From 75c49e6e30578752a8283c250728e6c808c4e5bd Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 8 Aug 2026 10:38:10 +1200 Subject: [PATCH] Add readable multipart operations. --- lib/protocol/multipart.rb | 1 + lib/protocol/multipart/form_data.rb | 3 + lib/protocol/multipart/parser.rb | 3 + lib/protocol/multipart/readable.rb | 63 +++++++++++++++++++ releases.md | 4 ++ test/protocol/multipart/form_data.rb | 4 +- test/protocol/multipart/parser.rb | 6 +- test/protocol/multipart/readable.rb | 91 ++++++++++++++++++++++++++++ 8 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 lib/protocol/multipart/readable.rb create mode 100644 test/protocol/multipart/readable.rb diff --git a/lib/protocol/multipart.rb b/lib/protocol/multipart.rb index e5623dd..1327809 100644 --- a/lib/protocol/multipart.rb +++ b/lib/protocol/multipart.rb @@ -7,6 +7,7 @@ require_relative "multipart/error" require_relative "multipart/byte_limit" require_relative "multipart/headers" +require_relative "multipart/readable" require_relative "multipart/parser" require_relative "multipart/mixed" require_relative "multipart/part" diff --git a/lib/protocol/multipart/form_data.rb b/lib/protocol/multipart/form_data.rb index 56cd459..a991344 100644 --- a/lib/protocol/multipart/form_data.rb +++ b/lib/protocol/multipart/form_data.rb @@ -8,6 +8,7 @@ require_relative "string_part" require_relative "escape" require_relative "byte_limit" +require_relative "readable" module Protocol module Multipart @@ -27,6 +28,8 @@ class FormData < Mixed # A file upload yielded while parsing form data. class Upload + include Readable + # Initialize a streamed file upload. # @parameter part [Parser::Part] The underlying multipart part. # @parameter filename [String] The submitted filename. diff --git a/lib/protocol/multipart/parser.rb b/lib/protocol/multipart/parser.rb index 4d80ea0..c96b123 100644 --- a/lib/protocol/multipart/parser.rb +++ b/lib/protocol/multipart/parser.rb @@ -6,6 +6,7 @@ require "io/stream" require_relative "error" require_relative "headers" +require_relative "readable" module Protocol module Multipart @@ -29,6 +30,8 @@ class Parser # Represents a single part within a multipart message. class Part + include Readable + # Initialize a new part with a readable stream, headers, and a boundary string. # # @parameter readable [IO::Stream] The readable stream that contains the part's data. diff --git a/lib/protocol/multipart/readable.rb b/lib/protocol/multipart/readable.rb new file mode 100644 index 0000000..e6807b3 --- /dev/null +++ b/lib/protocol/multipart/readable.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Protocol + module Multipart + # Common operations for streaming readable multipart content. + module Readable + # Copy the content to a writable object. + # @parameter output [#write] The writable destination. + # @parameter chunk_size [Integer] The maximum chunk size. + # @returns [Integer] The number of bytes copied. + def copy_to(output, chunk_size = 8192) + size = 0 + + each(chunk_size) do |chunk| + offset = 0 + + # A writable object may consume only part of a chunk: + while offset < chunk.bytesize + written = output.write(chunk.byteslice(offset, chunk.bytesize - offset)) + + unless written && written > 0 + raise IOError, "Could not make progress while copying multipart content!" + end + + offset += written + end + + size += chunk.bytesize + end + + return size + end + + # Save the content to a new local file. + # @parameter path [String, #to_path] The destination path, which must not exist. + # @parameter permissions [Integer] The permissions for the new file. + # @parameter chunk_size [Integer] The maximum chunk size. + # @returns [Integer] The number of bytes saved. + def save(path, permissions: 0o600, chunk_size: 8192) + flags = File::WRONLY | File::CREAT | File::EXCL + created = false + + begin + File.open(path, flags, permissions) do |file| + created = true + file.binmode + return copy_to(file, chunk_size) + end + rescue + # Remove a partial destination created by this operation: + if created + File.unlink(path) + end + + raise + end + end + end + end +end diff --git a/releases.md b/releases.md index 369e84e..bb325d9 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Add `copy_to` and secure local `save` operations for readable multipart parts and form-data uploads. + ## v0.6.0 - Rename `Protocol::Multipart::FormData::Parser::CONTENT_TYPE` to `MEDIA_TYPE`. diff --git a/test/protocol/multipart/form_data.rb b/test/protocol/multipart/form_data.rb index ac0e996..4d8fa5e 100644 --- a/test/protocol/multipart/form_data.rb +++ b/test/protocol/multipart/form_data.rb @@ -42,7 +42,9 @@ def parse_upload(value, **options) StringIO.new(serialize(form_data)), boundary: form_data.boundary, ) do |_name, upload| - upload.each.to_a.join + content = StringIO.new + upload.copy_to(content) + content.string end end diff --git a/test/protocol/multipart/parser.rb b/test/protocol/multipart/parser.rb index cbe12eb..e6c733c 100644 --- a/test/protocol/multipart/parser.rb +++ b/test/protocol/multipart/parser.rb @@ -19,9 +19,9 @@ expect(part.headers).to be_a(Protocol::Multipart::Headers) expect(part.headers["content-disposition"].type).to be == "form-data" expect(part.headers["content-disposition"]["filename"]).to be == "example.txt" - content = String.new - part.each{|chunk| content << chunk} - parts_data << content + content = StringIO.new + part.copy_to(content) + parts_data << content.string end expect(parts_data.size).to be == 1 diff --git a/test/protocol/multipart/readable.rb b/test/protocol/multipart/readable.rb new file mode 100644 index 0000000..bea8215 --- /dev/null +++ b/test/protocol/multipart/readable.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/multipart/readable" + +require "stringio" +require "tmpdir" + +describe Protocol::Multipart::Readable do + let(:readable) do + Class.new do + include Protocol::Multipart::Readable + + def each(chunk_size = 8192) + return to_enum(:each, chunk_size) unless block_given? + + yield "content" + end + end.new + end + + it "copies content to a writable object" do + output = StringIO.new + + expect(readable.copy_to(output)).to be == 7 + expect(output.string).to be == "content" + end + + it "handles partial writes" do + output = String.new + writable = Object.new + writable.define_singleton_method(:write) do |chunk| + output << chunk.byteslice(0, 1) + 1 + end + + expect(readable.copy_to(writable)).to be == 7 + expect(output).to be == "content" + end + + it "rejects writes which cannot make progress" do + writable = Object.new + writable.define_singleton_method(:write){|_chunk| 0} + + expect do + readable.copy_to(writable) + end.to raise_exception(IOError, message: be =~ /make progress/) + end + + it "saves content to a new private file" do + Dir.mktmpdir do |directory| + path = File.join(directory, "content") + + expect(readable.save(path)).to be == 7 + expect(File.binread(path)).to be == "content" + expect(File.stat(path).mode & 0o777).to be == 0o600 + end + end + + it "does not replace an existing file" do + Dir.mktmpdir do |directory| + path = File.join(directory, "content") + File.write(path, "existing") + + expect do + readable.save(path) + end.to raise_exception(Errno::EEXIST) + + expect(File.read(path)).to be == "existing" + end + end + + it "removes a partial file when copying fails" do + readable.define_singleton_method(:each) do |_chunk_size = 8192, &block| + block.call("partial") + raise "Copy failed!" + end + + Dir.mktmpdir do |directory| + path = File.join(directory, "content") + + expect do + readable.save(path) + end.to raise_exception(RuntimeError, message: be == "Copy failed!") + + expect(File.exist?(path)).to be == false + end + end +end