protocol-multipart 0.1.1 → 0.2.0
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
- checksums.yaml.gz.sig +0 -0
- data/lib/protocol/multipart/byte_limit.rb +40 -0
- data/lib/protocol/multipart/form_data.rb +108 -0
- data/lib/protocol/multipart/header/content_disposition.rb +18 -0
- data/lib/protocol/multipart/header/content_type.rb +18 -0
- data/lib/protocol/multipart/header/parameterized.rb +106 -0
- data/lib/protocol/multipart/headers.rb +41 -0
- data/lib/protocol/multipart/parser.rb +77 -32
- data/lib/protocol/multipart/version.rb +1 -1
- data/lib/protocol/multipart.rb +2 -0
- data/readme.md +23 -0
- data/releases.md +7 -0
- data.tar.gz.sig +0 -0
- metadata +22 -3
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f780cb8777ab2bcdacd17295c0017d194f0e43e864b76064c2f5a17feaeebce
|
|
4
|
+
data.tar.gz: 5b6c27f78eb2dc61fe351ae3c493847413580af1b6c194cd090a6eeca9a46ad6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '08d99314fcd9dbafde62056380a4d17a4288f5d0fef2cc33a00ac55ddcfd9bf2dfeaabf4e26c209a8b94b7bd01c553e883767323143b7f2f55e04e02a571ef0c'
|
|
7
|
+
data.tar.gz: 0770603bb53169f98a8fd2582d464c1677fe906fddbebc3470f14e426d3bdc4db9cf05a3757ba615724fefa9510ad7cb11085becbc884d7950593a93ae00f3f1
|
checksums.yaml.gz.sig
CHANGED
|
Binary file
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
module Protocol
|
|
7
|
+
module Multipart
|
|
8
|
+
# Tracks consumed bytes against an optional maximum.
|
|
9
|
+
class ByteLimit
|
|
10
|
+
# Initialize a byte limit.
|
|
11
|
+
# @parameter maximum [Integer | Nil] The maximum number of bytes, or nil for no limit.
|
|
12
|
+
# @parameter name [Symbol] The name used when reporting a limit violation.
|
|
13
|
+
def initialize(maximum, name: :size)
|
|
14
|
+
if maximum and maximum < 0
|
|
15
|
+
raise ArgumentError, "Multipart limits must be non-negative!"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
@maximum = maximum
|
|
19
|
+
@name = name
|
|
20
|
+
@size = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The number of bytes consumed.
|
|
24
|
+
attr :size
|
|
25
|
+
|
|
26
|
+
# Consume the given number of bytes.
|
|
27
|
+
# @parameter size [Integer] The number of bytes to consume.
|
|
28
|
+
# @returns [Integer] The total number of bytes consumed.
|
|
29
|
+
def consume(size)
|
|
30
|
+
@size += size
|
|
31
|
+
|
|
32
|
+
if @maximum and @size > @maximum
|
|
33
|
+
raise RangeError, "Multipart #{@name} exceeded limit of #{@maximum}!"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
return @size
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
# Copyright, 2025, by Samuel Williams.
|
|
5
5
|
|
|
6
6
|
require_relative "mixed"
|
|
7
|
+
require_relative "parser"
|
|
7
8
|
require_relative "string_part"
|
|
8
9
|
require_relative "escape"
|
|
10
|
+
require_relative "byte_limit"
|
|
9
11
|
|
|
10
12
|
module Protocol
|
|
11
13
|
module Multipart
|
|
@@ -14,6 +16,112 @@ module Protocol
|
|
|
14
16
|
class FormData < Mixed
|
|
15
17
|
include Escape
|
|
16
18
|
|
|
19
|
+
# The default maximum size of a buffered form field.
|
|
20
|
+
MAXIMUM_FIELD_SIZE = 2 * 1024 * 1024
|
|
21
|
+
|
|
22
|
+
# The default maximum size of a streamed file upload.
|
|
23
|
+
MAXIMUM_UPLOAD_SIZE = 128 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
# The default maximum combined size of all form fields and file uploads.
|
|
26
|
+
MAXIMUM_TOTAL_SIZE = 256 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
# A file upload yielded while parsing form data.
|
|
29
|
+
class Upload
|
|
30
|
+
# Initialize a streamed file upload.
|
|
31
|
+
# @parameter part [Parser::Part] The underlying multipart part.
|
|
32
|
+
# @parameter filename [String] The submitted filename.
|
|
33
|
+
# @parameter maximum_size [Integer | Nil] The maximum upload size.
|
|
34
|
+
# @parameter total_limit [ByteLimit] The shared form-data size limit.
|
|
35
|
+
def initialize(part, filename, maximum_size, total_limit)
|
|
36
|
+
@part = part
|
|
37
|
+
@filename = filename
|
|
38
|
+
@limit = ByteLimit.new(maximum_size, name: :upload_size)
|
|
39
|
+
@total_limit = total_limit
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The submitted filename.
|
|
43
|
+
attr :filename
|
|
44
|
+
|
|
45
|
+
# The multipart headers associated with this upload.
|
|
46
|
+
def headers
|
|
47
|
+
@part.headers
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The number of upload bytes consumed so far.
|
|
51
|
+
def size
|
|
52
|
+
@limit.size
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Whether the complete upload has been consumed.
|
|
56
|
+
def ended?
|
|
57
|
+
@part.ended?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Iterate over the upload body.
|
|
61
|
+
# @parameter chunk_size [Integer] The maximum chunk size.
|
|
62
|
+
def each(chunk_size = 8192)
|
|
63
|
+
return to_enum(:each, chunk_size) unless block_given?
|
|
64
|
+
|
|
65
|
+
@part.each(chunk_size) do |chunk|
|
|
66
|
+
@limit.consume(chunk.bytesize)
|
|
67
|
+
@total_limit.consume(chunk.bytesize)
|
|
68
|
+
yield chunk
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Consume any unread upload body while applying its limits.
|
|
75
|
+
def discard
|
|
76
|
+
self.each{|chunk|}
|
|
77
|
+
return nil
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Parse multipart form data.
|
|
82
|
+
#
|
|
83
|
+
# Fields are yielded as strings. File uploads are yielded as streaming {Upload} instances and are only readable during the corresponding block invocation.
|
|
84
|
+
#
|
|
85
|
+
# @parameter readable [IO, IO::Stream] The readable stream containing multipart form data.
|
|
86
|
+
# @parameter boundary [String] The multipart boundary.
|
|
87
|
+
# @parameter maximum_field_size [Integer | Nil] The maximum size of each buffered field.
|
|
88
|
+
# @parameter maximum_upload_size [Integer | Nil] The maximum size of each file upload.
|
|
89
|
+
# @parameter maximum_total_size [Integer | Nil] The maximum combined size of all fields and uploads.
|
|
90
|
+
# @yields {|name, value| ...} Each form field name and its string or streaming upload value.
|
|
91
|
+
def self.parse(readable, boundary, maximum_field_size: MAXIMUM_FIELD_SIZE, maximum_upload_size: MAXIMUM_UPLOAD_SIZE, maximum_total_size: MAXIMUM_TOTAL_SIZE, **options)
|
|
92
|
+
unless block_given?
|
|
93
|
+
return enum_for(__method__, readable, boundary, maximum_field_size: maximum_field_size, maximum_upload_size: maximum_upload_size, maximum_total_size: maximum_total_size, **options)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
total_limit = ByteLimit.new(maximum_total_size, name: :total_size)
|
|
97
|
+
parser = Parser.new(readable, boundary, **options)
|
|
98
|
+
|
|
99
|
+
parser.each do |part|
|
|
100
|
+
disposition = part.headers["content-disposition"]
|
|
101
|
+
|
|
102
|
+
unless disposition&.type == "form-data" and name = disposition["name"]
|
|
103
|
+
raise ArgumentError, "Multipart form part is missing a form-data name!"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if filename = disposition["filename"]
|
|
107
|
+
upload = Upload.new(part, filename, maximum_upload_size, total_limit)
|
|
108
|
+
yield name, upload
|
|
109
|
+
upload.discard
|
|
110
|
+
else
|
|
111
|
+
field_limit = ByteLimit.new(maximum_field_size, name: :field_size)
|
|
112
|
+
value = String.new.b
|
|
113
|
+
|
|
114
|
+
part.each do |chunk|
|
|
115
|
+
field_limit.consume(chunk.bytesize)
|
|
116
|
+
total_limit.consume(chunk.bytesize)
|
|
117
|
+
value << chunk
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
yield name, value
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
17
125
|
# Returns the MIME type for form data.
|
|
18
126
|
#
|
|
19
127
|
# @returns [String] The MIME type "multipart/form-data".
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "parameterized"
|
|
7
|
+
|
|
8
|
+
module Protocol
|
|
9
|
+
module Multipart
|
|
10
|
+
module Header
|
|
11
|
+
# A MIME Content-Disposition header value.
|
|
12
|
+
class ContentDisposition < Parameterized
|
|
13
|
+
VALUE_PATTERN = /\A[ \t]*(#{TOKEN})[ \t]*/.freeze
|
|
14
|
+
NAME = "content-disposition"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "parameterized"
|
|
7
|
+
|
|
8
|
+
module Protocol
|
|
9
|
+
module Multipart
|
|
10
|
+
module Header
|
|
11
|
+
# A MIME Content-Type header value.
|
|
12
|
+
class ContentType < Parameterized
|
|
13
|
+
VALUE_PATTERN = /\A[ \t]*(#{TOKEN}\/#{TOKEN})[ \t]*/.freeze
|
|
14
|
+
NAME = "content-type"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "protocol/http/error"
|
|
7
|
+
|
|
8
|
+
module Protocol
|
|
9
|
+
module Multipart
|
|
10
|
+
module Header
|
|
11
|
+
# A parameterized MIME header value.
|
|
12
|
+
class Parameterized
|
|
13
|
+
TOKEN = "[!#$%&'*+\\-.^_`|~0-9A-Za-z]+"
|
|
14
|
+
VALUE_PATTERN = /\A[ \t]*(#{TOKEN}(?:\/#{TOKEN})?)[ \t]*/.freeze
|
|
15
|
+
PARAMETER_PATTERN = /\G;[ \t]*(#{TOKEN})[ \t]*=[ \t]*(?:"((?:\\[^\x00-\x1f\x7f]|[^"\\\x00-\x1f\x7f])*)"|(#{TOKEN}))[ \t]*/.freeze
|
|
16
|
+
NAME = nil
|
|
17
|
+
|
|
18
|
+
# Parse a parameterized header value.
|
|
19
|
+
# @parameter string [String] The header value.
|
|
20
|
+
# @returns [Parameterized] The parsed header.
|
|
21
|
+
# @raises [ArgumentError] If the header value is malformed or contains duplicate parameters.
|
|
22
|
+
def self.parse(string)
|
|
23
|
+
unless match = self::VALUE_PATTERN.match(string)
|
|
24
|
+
raise ArgumentError, "Invalid header value: #{string.inspect}!"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
type = match[1].downcase
|
|
28
|
+
parameters = {}
|
|
29
|
+
offset = match.end(0)
|
|
30
|
+
|
|
31
|
+
while offset < string.length
|
|
32
|
+
unless match = PARAMETER_PATTERN.match(string, offset)
|
|
33
|
+
raise ArgumentError, "Invalid header parameter at offset #{offset}: #{string.inspect}!"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
name = match[1].downcase
|
|
37
|
+
|
|
38
|
+
if parameters.key?(name)
|
|
39
|
+
raise ArgumentError, "Duplicate header parameter: #{name.inspect}!"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if quoted = match[2]
|
|
43
|
+
parameters[name] = quoted.gsub(/\\(.)/, "\\1")
|
|
44
|
+
else
|
|
45
|
+
parameters[name] = match[3]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
offset = match.end(0)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
return new(type, parameters)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Coerce an object to a parameterized header value.
|
|
55
|
+
# @parameter value [Object] The header value.
|
|
56
|
+
# @returns [Parameterized] The parsed header.
|
|
57
|
+
def self.coerce(value)
|
|
58
|
+
if value.is_a?(self)
|
|
59
|
+
return value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
return parse(value.to_s)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Initialize a parameterized header value.
|
|
66
|
+
# @parameter type [String] The primary header value.
|
|
67
|
+
# @parameter parameters [Hash] The named parameters.
|
|
68
|
+
def initialize(type, parameters = {})
|
|
69
|
+
@type = type
|
|
70
|
+
@parameters = parameters
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The primary header value.
|
|
74
|
+
attr :type
|
|
75
|
+
|
|
76
|
+
# The named parameters.
|
|
77
|
+
attr :parameters
|
|
78
|
+
|
|
79
|
+
# Fetch a named parameter.
|
|
80
|
+
# @parameter name [String] The case-insensitive parameter name.
|
|
81
|
+
# @returns [String | Nil] The parameter value, if present.
|
|
82
|
+
def [](name)
|
|
83
|
+
@parameters[name.downcase]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Reject a second value for this singular MIME field.
|
|
87
|
+
def <<(value)
|
|
88
|
+
raise Protocol::HTTP::DuplicateHeaderError.new(self.class::NAME, self, value)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Convert the header to its wire representation.
|
|
92
|
+
# @returns [String] The serialized header value.
|
|
93
|
+
def to_s
|
|
94
|
+
value = String.new(@type)
|
|
95
|
+
|
|
96
|
+
@parameters.each do |name, parameter|
|
|
97
|
+
escaped = parameter.gsub(/["\\]/, "\\\\\\0")
|
|
98
|
+
value << "; #{name}=\"#{escaped}\""
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
return value
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2026, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "protocol/http/headers"
|
|
7
|
+
|
|
8
|
+
require_relative "header/content_disposition"
|
|
9
|
+
require_relative "header/content_type"
|
|
10
|
+
|
|
11
|
+
module Protocol
|
|
12
|
+
module Multipart
|
|
13
|
+
# @namespace
|
|
14
|
+
module Header
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The header fields associated with a multipart body or part.
|
|
18
|
+
class Headers < Protocol::HTTP::Headers
|
|
19
|
+
POLICY = {
|
|
20
|
+
"content-description" => false,
|
|
21
|
+
"content-disposition" => Header::ContentDisposition,
|
|
22
|
+
"content-id" => false,
|
|
23
|
+
"content-length" => false,
|
|
24
|
+
"content-transfer-encoding" => false,
|
|
25
|
+
"content-type" => Header::ContentType,
|
|
26
|
+
"mime-version" => false,
|
|
27
|
+
}.tap do |policy|
|
|
28
|
+
policy.default = Protocol::HTTP::Header::Multiple
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Initialize the multipart headers.
|
|
32
|
+
# @parameter fields [Array] An array of `[key, value]` pairs.
|
|
33
|
+
# @parameter tail [Integer | Nil] The index of the trailer start.
|
|
34
|
+
# @parameter indexed [Hash | Nil] The cached header index.
|
|
35
|
+
# @parameter policy [Hash] The header normalization policy.
|
|
36
|
+
def initialize(fields = [], tail = nil, indexed: nil, policy: POLICY)
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -4,18 +4,34 @@
|
|
|
4
4
|
# Copyright, 2025, by Samuel Williams.
|
|
5
5
|
|
|
6
6
|
require "io/stream"
|
|
7
|
+
require_relative "headers"
|
|
7
8
|
|
|
8
9
|
module Protocol
|
|
9
10
|
module Multipart
|
|
10
11
|
# A parser for multipart data based on RFC 2046 and RFC 2387.
|
|
11
12
|
# Parses multipart bodies and provides an enumerable interface to access the parts.
|
|
12
13
|
class Parser
|
|
14
|
+
HEADER_PATTERN = /\A([!-9;-~]+):[ \t]*([^\x00-\x08\x0a-\x1f\x7f]*)\z/.freeze
|
|
15
|
+
private_constant :HEADER_PATTERN
|
|
16
|
+
|
|
17
|
+
# The default maximum number of preamble bytes before the first boundary.
|
|
18
|
+
MAXIMUM_PREAMBLE_SIZE = 64 * 1024
|
|
19
|
+
|
|
20
|
+
# The default maximum number of header bytes in each part.
|
|
21
|
+
MAXIMUM_HEADER_SIZE = 64 * 1024
|
|
22
|
+
|
|
23
|
+
# The default maximum number of headers in each part.
|
|
24
|
+
MAXIMUM_HEADER_COUNT = 64
|
|
25
|
+
|
|
26
|
+
# The default maximum number of parts.
|
|
27
|
+
MAXIMUM_PART_COUNT = 128
|
|
28
|
+
|
|
13
29
|
# Represents a single part within a multipart message.
|
|
14
30
|
class Part
|
|
15
31
|
# Initialize a new part with a readable stream, headers, and a boundary string.
|
|
16
32
|
#
|
|
17
33
|
# @parameter readable [IO::Stream] The readable stream that contains the part's data.
|
|
18
|
-
# @parameter headers [
|
|
34
|
+
# @parameter headers [Headers] The headers associated with this part.
|
|
19
35
|
# @parameter boundary [String] The boundary string used to separate parts.
|
|
20
36
|
def initialize(readable, headers, boundary)
|
|
21
37
|
@readable = readable
|
|
@@ -25,7 +41,7 @@ module Protocol
|
|
|
25
41
|
@is_closing = false
|
|
26
42
|
end
|
|
27
43
|
|
|
28
|
-
# @attribute [
|
|
44
|
+
# @attribute [Headers] The headers associated with this part.
|
|
29
45
|
attr_reader :headers
|
|
30
46
|
|
|
31
47
|
# Iterate through the part content in chunks.
|
|
@@ -147,9 +163,23 @@ module Protocol
|
|
|
147
163
|
#
|
|
148
164
|
# @parameter readable [IO, IO::Stream] The readable stream containing multipart data.
|
|
149
165
|
# @parameter boundary [String] The boundary string that separates the parts.
|
|
150
|
-
|
|
166
|
+
# @parameter maximum_preamble_size [Integer | Nil] The maximum preamble size, or nil for no limit.
|
|
167
|
+
# @parameter maximum_header_size [Integer | Nil] The maximum header size per part, or nil for no limit.
|
|
168
|
+
# @parameter maximum_header_count [Integer | Nil] The maximum header count per part, or nil for no limit.
|
|
169
|
+
# @parameter maximum_part_count [Integer | Nil] The maximum part count, or nil for no limit.
|
|
170
|
+
def initialize(readable, boundary, maximum_preamble_size: MAXIMUM_PREAMBLE_SIZE, maximum_header_size: MAXIMUM_HEADER_SIZE, maximum_header_count: MAXIMUM_HEADER_COUNT, maximum_part_count: MAXIMUM_PART_COUNT)
|
|
171
|
+
limits = [maximum_preamble_size, maximum_header_size, maximum_header_count, maximum_part_count]
|
|
172
|
+
|
|
173
|
+
if limits.any?{|limit| limit and limit < 0}
|
|
174
|
+
raise ArgumentError, "Multipart limits must be non-negative!"
|
|
175
|
+
end
|
|
176
|
+
|
|
151
177
|
@readable = IO::Stream(readable)
|
|
152
178
|
@boundary = boundary
|
|
179
|
+
@maximum_preamble_size = maximum_preamble_size
|
|
180
|
+
@maximum_header_size = maximum_header_size
|
|
181
|
+
@maximum_header_count = maximum_header_count
|
|
182
|
+
@maximum_part_count = maximum_part_count
|
|
153
183
|
|
|
154
184
|
@boundary_marker = "--#{@boundary}\r\n".freeze
|
|
155
185
|
end
|
|
@@ -161,11 +191,16 @@ module Protocol
|
|
|
161
191
|
def each
|
|
162
192
|
return to_enum unless block_given?
|
|
163
193
|
|
|
194
|
+
preamble_size = 0
|
|
195
|
+
|
|
164
196
|
# Read lines until we find the first boundary:
|
|
165
197
|
while true
|
|
166
|
-
if line = @
|
|
198
|
+
if line = read_line(preamble_size, @maximum_preamble_size, allowance: @boundary_marker.bytesize, chomp: false)
|
|
167
199
|
if line == @boundary_marker
|
|
168
200
|
break
|
|
201
|
+
else
|
|
202
|
+
preamble_size += line.bytesize
|
|
203
|
+
check_limit(:preamble_size, preamble_size, @maximum_preamble_size)
|
|
169
204
|
end
|
|
170
205
|
else
|
|
171
206
|
# End of stream reached without finding boundary:
|
|
@@ -173,18 +208,21 @@ module Protocol
|
|
|
173
208
|
end
|
|
174
209
|
end
|
|
175
210
|
|
|
211
|
+
part_count = 0
|
|
212
|
+
|
|
176
213
|
while true
|
|
214
|
+
part_count += 1
|
|
215
|
+
check_limit(:part_count, part_count, @maximum_part_count)
|
|
216
|
+
|
|
177
217
|
part = read_part
|
|
178
218
|
break unless part
|
|
179
219
|
|
|
180
220
|
if part.read_empty_boundary?
|
|
181
221
|
else
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
part.discard
|
|
187
|
-
end
|
|
222
|
+
yield part
|
|
223
|
+
|
|
224
|
+
# Advance to the next boundary after the consumer returns normally. If the consumer raises, stop parsing without draining the request body.
|
|
225
|
+
part.discard
|
|
188
226
|
end
|
|
189
227
|
|
|
190
228
|
# Check if this was the last part:
|
|
@@ -196,34 +234,41 @@ module Protocol
|
|
|
196
234
|
|
|
197
235
|
private
|
|
198
236
|
|
|
237
|
+
def read_line(size, maximum, allowance: 0, chomp:)
|
|
238
|
+
if maximum
|
|
239
|
+
limit = maximum - size + allowance + 1
|
|
240
|
+
return @readable.gets("\r\n", limit, chomp: chomp)
|
|
241
|
+
else
|
|
242
|
+
return @readable.gets("\r\n", chomp: chomp)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def check_limit(name, value, maximum)
|
|
247
|
+
if maximum and value > maximum
|
|
248
|
+
raise RangeError, "Multipart #{name} exceeded limit of #{maximum}!"
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
199
252
|
def read_part
|
|
200
|
-
|
|
201
|
-
|
|
253
|
+
fields = []
|
|
254
|
+
header_size = 0
|
|
255
|
+
header_count = 0
|
|
202
256
|
|
|
203
257
|
# Read headers until empty line
|
|
204
|
-
while line =
|
|
258
|
+
while line = read_line(header_size, @maximum_header_size, allowance: 2, chomp: true)
|
|
205
259
|
if line.empty?
|
|
206
260
|
break # End of headers
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
elsif match = line.match(/^([^:]+):\s*(.*)$/)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
header_size += line.bytesize + 2
|
|
264
|
+
check_limit(:header_size, header_size, @maximum_header_size)
|
|
265
|
+
|
|
266
|
+
if match = line.match(HEADER_PATTERN)
|
|
214
267
|
# Parse header line (name: value)
|
|
215
|
-
|
|
216
|
-
|
|
268
|
+
header_count += 1
|
|
269
|
+
check_limit(:header_count, header_count, @maximum_header_count)
|
|
217
270
|
|
|
218
|
-
|
|
219
|
-
if current.is_a?(Array)
|
|
220
|
-
current << value
|
|
221
|
-
else
|
|
222
|
-
headers[name] = [current, value]
|
|
223
|
-
end
|
|
224
|
-
else
|
|
225
|
-
headers[name] = value
|
|
226
|
-
end
|
|
271
|
+
fields << [match[1], match[2].strip]
|
|
227
272
|
else
|
|
228
273
|
raise RuntimeError, "Invalid header line: #{line.inspect}"
|
|
229
274
|
end
|
|
@@ -233,7 +278,7 @@ module Protocol
|
|
|
233
278
|
raise EOFError, "Unexpected end of stream while reading headers!"
|
|
234
279
|
end
|
|
235
280
|
|
|
236
|
-
return Part.new(@readable,
|
|
281
|
+
return Part.new(@readable, Headers.new(fields), @boundary)
|
|
237
282
|
end
|
|
238
283
|
end
|
|
239
284
|
end
|
data/lib/protocol/multipart.rb
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
# Copyright, 2025, by Samuel Williams.
|
|
5
5
|
|
|
6
6
|
require_relative "multipart/version"
|
|
7
|
+
require_relative "multipart/byte_limit"
|
|
8
|
+
require_relative "multipart/headers"
|
|
7
9
|
require_relative "multipart/parser"
|
|
8
10
|
require_relative "multipart/mixed"
|
|
9
11
|
require_relative "multipart/part"
|
data/readme.md
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
Please see the [project releases](https://socketry.github.io/protocol-multipart/releases/index) for all releases.
|
|
8
8
|
|
|
9
|
+
### v0.2.0
|
|
10
|
+
|
|
11
|
+
- Add strict, policy-driven parsing for parameterized `Content-Type` and `Content-Disposition` fields using `Protocol::Multipart::Headers`.
|
|
12
|
+
- Limit multipart preamble size, header size, header count and part count by default.
|
|
13
|
+
- Add `Protocol::Multipart::ByteLimit` for limiting streamed multipart content.
|
|
14
|
+
- Add streaming multipart form-data parsing with field, upload and total content limits.
|
|
15
|
+
|
|
9
16
|
### v0.1.0
|
|
10
17
|
|
|
11
18
|
## Contributing
|
|
@@ -18,6 +25,22 @@ We welcome contributions to this project.
|
|
|
18
25
|
4. Push to the branch (`git push origin my-new-feature`).
|
|
19
26
|
5. Create new Pull Request.
|
|
20
27
|
|
|
28
|
+
### Running Tests
|
|
29
|
+
|
|
30
|
+
To run the test suite:
|
|
31
|
+
|
|
32
|
+
``` shell
|
|
33
|
+
bundle exec sus
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Making Releases
|
|
37
|
+
|
|
38
|
+
To make a new release:
|
|
39
|
+
|
|
40
|
+
``` shell
|
|
41
|
+
bundle exec bake gem:release:patch # or minor or major
|
|
42
|
+
```
|
|
43
|
+
|
|
21
44
|
### Developer Certificate of Origin
|
|
22
45
|
|
|
23
46
|
In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
|
data/releases.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
# Releases
|
|
2
2
|
|
|
3
|
+
## v0.2.0
|
|
4
|
+
|
|
5
|
+
- Add strict, policy-driven parsing for parameterized `Content-Type` and `Content-Disposition` fields using `Protocol::Multipart::Headers`.
|
|
6
|
+
- Limit multipart preamble size, header size, header count and part count by default.
|
|
7
|
+
- Add `Protocol::Multipart::ByteLimit` for limiting streamed multipart content.
|
|
8
|
+
- Add streaming multipart form-data parsing with field, upload and total content limits.
|
|
9
|
+
|
|
3
10
|
## v0.1.0
|
data.tar.gz.sig
CHANGED
|
Binary file
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: protocol-multipart
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Samuel Williams
|
|
@@ -52,14 +52,33 @@ dependencies:
|
|
|
52
52
|
- - "~>"
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
54
|
version: '0.8'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: protocol-http
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '0.67'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0.67'
|
|
55
69
|
executables: []
|
|
56
70
|
extensions: []
|
|
57
71
|
extra_rdoc_files: []
|
|
58
72
|
files:
|
|
59
73
|
- lib/protocol/multipart.rb
|
|
60
74
|
- lib/protocol/multipart/boundary.rb
|
|
75
|
+
- lib/protocol/multipart/byte_limit.rb
|
|
61
76
|
- lib/protocol/multipart/escape.rb
|
|
62
77
|
- lib/protocol/multipart/form_data.rb
|
|
78
|
+
- lib/protocol/multipart/header/content_disposition.rb
|
|
79
|
+
- lib/protocol/multipart/header/content_type.rb
|
|
80
|
+
- lib/protocol/multipart/header/parameterized.rb
|
|
81
|
+
- lib/protocol/multipart/headers.rb
|
|
63
82
|
- lib/protocol/multipart/io_part.rb
|
|
64
83
|
- lib/protocol/multipart/mixed.rb
|
|
65
84
|
- lib/protocol/multipart/parser.rb
|
|
@@ -82,14 +101,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
82
101
|
requirements:
|
|
83
102
|
- - ">="
|
|
84
103
|
- !ruby/object:Gem::Version
|
|
85
|
-
version: '3.
|
|
104
|
+
version: '3.3'
|
|
86
105
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
87
106
|
requirements:
|
|
88
107
|
- - ">="
|
|
89
108
|
- !ruby/object:Gem::Version
|
|
90
109
|
version: '0'
|
|
91
110
|
requirements: []
|
|
92
|
-
rubygems_version:
|
|
111
|
+
rubygems_version: 4.0.10
|
|
93
112
|
specification_version: 4
|
|
94
113
|
summary: Provides abstractions to handle the multipart format.
|
|
95
114
|
test_files: []
|
metadata.gz.sig
CHANGED
|
Binary file
|