Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/protocol/multipart.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions lib/protocol/multipart/form_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
require_relative "string_part"
require_relative "escape"
require_relative "byte_limit"
require_relative "readable"

module Protocol
module Multipart
Expand All @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions lib/protocol/multipart/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require "io/stream"
require_relative "error"
require_relative "headers"
require_relative "readable"

module Protocol
module Multipart
Expand All @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions lib/protocol/multipart/readable.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions releases.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
4 changes: 3 additions & 1 deletion test/protocol/multipart/form_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions test/protocol/multipart/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions test/protocol/multipart/readable.rb
Original file line number Diff line number Diff line change
@@ -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
Loading