files.com 1.1.794 → 1.1.796

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.
@@ -2,9 +2,12 @@ require "spec_helper"
2
2
  require "stringio"
3
3
  require "tmpdir"
4
4
  require "zlib"
5
+ require_relative "../support/stubbed_api"
5
6
  require_relative "../support/transfer_server"
6
7
 
7
8
  RSpec.describe Files::File, "transfers" do
9
+ include StubbedApi
10
+
8
11
  # 22,528 bytes that include every byte value, so text handling would show.
9
12
  let(:payload) { (0..255).map(&:chr).join.b * 88 }
10
13
  let(:signed_target) { "/objects/report.bin?X-Amz-Credential=fixture%2Fcredential&X-Amz-Signature=fixture-signature" }
@@ -53,6 +56,118 @@ RSpec.describe Files::File, "transfers" do
53
56
  complete ? "#{encoded}0\r\n\r\n" : encoded
54
57
  end
55
58
 
59
+ # The API and storage an upload sends through: the SDK's own connection with Faraday test stubs. Each begin_upload of
60
+ # report.bin grants the parts it asks for, partsize bytes each, at https://storage.example.test/parts/<number>; each
61
+ # part request is answered by part_response (200 with an ETag unless given), and the end of the upload with 201.
62
+ # requests keeps every request in order, with its parameters or body and its headers. A planning request past the
63
+ # tenth fails, so an upload that never ends stops.
64
+ def upload_api(requests, partsize:, part_response: nil)
65
+ stubbed_api_client do |stub|
66
+ stub.post("/api/rest/v1/file_actions/begin_upload/report.bin") do |env|
67
+ params = Faraday::Utils.parse_nested_query(env.body)
68
+ requests << [ :begin, params, env.request_headers ]
69
+ raise "The upload planned more than ten parts" if requests.count { |kind, *| kind == :begin } > 10
70
+
71
+ first = Integer(params.fetch("part"))
72
+ json_response(200, (first...(first + Integer(params.fetch("parts")))).map do |number|
73
+ { part_number: number, partsize: partsize, ref: "upload-ref", http_method: "PUT", upload_uri: "https://storage.example.test/parts/#{number}" }
74
+ end
75
+ )
76
+ end
77
+ stub.put(/\A\/parts\/\d+\z/) do |env|
78
+ number = Integer(env.url.path.split("/").last)
79
+ requests << [ :part, number, env.body, env.request_headers ]
80
+ part_response ? part_response.call(number) : [ 200, { "ETag" => "\"etag-#{number}\"" }, "" ]
81
+ end
82
+ stub.post("/api/rest/v1/files/report.bin") do |env|
83
+ requests << [ :end, Faraday::Utils.parse_nested_query(env.body), env.request_headers ]
84
+ json_response(201, { path: "report.bin", type: "file" })
85
+ end
86
+ end
87
+ end
88
+
89
+ def parts_sent(requests)
90
+ requests.select { |kind, *| kind == :part }.map { |_, number, body| [ number, body ] }
91
+ end
92
+
93
+ # A source that has only read, eof? and close: it gives its data at most max_read bytes a read, then, at its end,
94
+ # end_read (nil or an empty string). From stall_at on, every read gives an empty string although eof? is false. It
95
+ # refuses its hundred-and-first read, so a loop that never ends stops.
96
+ def short_reader(data, max_read:, end_read: nil, stall_at: nil)
97
+ offset = 0
98
+ reads = 0
99
+ Object.new.tap do |reader|
100
+ reader.define_singleton_method(:read) do |length = nil, _outbuf = nil|
101
+ raise "The source was read more than a hundred times" if (reads += 1) > 100
102
+ next "" if stall_at && offset >= stall_at
103
+ next end_read if offset >= data.bytesize
104
+
105
+ data.byteslice(offset, [ max_read, length || max_read ].min).tap { |chunk| offset += chunk.bytesize }
106
+ end
107
+ reader.define_singleton_method(:eof?) { offset >= data.bytesize }
108
+ reader.define_singleton_method(:close) { nil }
109
+ end
110
+ end
111
+
112
+ # Runs block with the path of a new local file holding data.
113
+ def with_local_file(data)
114
+ Dir.mktmpdir do |dir|
115
+ path = File.join(dir, "report.bin")
116
+ File.binwrite(path, data)
117
+ yield path
118
+ end
119
+ end
120
+
121
+ describe ".upload_chunks" do
122
+ it "fills each granted part from as many short reads as it takes" do
123
+ requests = []
124
+ described_class.upload_chunks(short_reader("abcdefghij", max_read: 3), "report.bin", { client: upload_api(requests, partsize: 4) })
125
+
126
+ expect(parts_sent(requests)).to eq([ [ 1, "abcd" ], [ 2, "efgh" ], [ 3, "ij" ] ])
127
+ end
128
+
129
+ it "ends a part at the source's end when its last read gives an empty string" do
130
+ requests = []
131
+ described_class.upload_chunks(short_reader("abcdefghij", max_read: 4, end_read: ""), "report.bin", { client: upload_api(requests, partsize: 4) })
132
+
133
+ expect(parts_sent(requests)).to eq([ [ 1, "abcd" ], [ 2, "efgh" ], [ 3, "ij" ] ])
134
+ end
135
+
136
+ it "refuses a source that gives nothing before its end, before sending that part" do
137
+ requests = []
138
+ reader = short_reader("abcdefghij", max_read: 4, stall_at: 4)
139
+
140
+ expect { described_class.upload_chunks(reader, "report.bin", { client: upload_api(requests, partsize: 4) }) }
141
+ .to raise_error(Files::InvalidParameterError, "The upload source returned no bytes before its end")
142
+ expect(parts_sent(requests)).to eq([ [ 1, "abcd" ] ])
143
+ end
144
+
145
+ it "lets a read's own error through, sending nothing after it" do
146
+ requests = []
147
+ read_error = IOError.new("the source failed")
148
+ reader = short_reader("abcdefghij", max_read: 4)
149
+ first_read = reader.method(:read)
150
+ reads = 0
151
+ reader.define_singleton_method(:read) { |*args| (reads += 1) == 2 ? raise(read_error) : first_read.call(*args) }
152
+
153
+ expect { described_class.upload_chunks(reader, "report.bin", { client: upload_api(requests, partsize: 4) }) }.to raise_error(IOError) { |error| expect(error).to equal(read_error) }
154
+ expect(parts_sent(requests)).to eq([ [ 1, "abcd" ] ])
155
+ end
156
+
157
+ it "reports each acknowledged part to progress, with an unknown total and this call's acknowledged bytes" do
158
+ requests = []
159
+ reports = []
160
+ described_class.upload_chunks(short_reader("abcdefghij", max_read: 3), "report.bin", { client: upload_api(requests, partsize: 4) }, progress: ->(report) { reports << report })
161
+
162
+ expect(reports).to eq([
163
+ { total_bytes: nil, acknowledged_bytes: 4, part_number: 1, part_bytes: 4 },
164
+ { total_bytes: nil, acknowledged_bytes: 8, part_number: 2, part_bytes: 4 },
165
+ { total_bytes: nil, acknowledged_bytes: 10, part_number: 3, part_bytes: 2 }
166
+ ]
167
+ )
168
+ end
169
+ end
170
+
56
171
  describe ".upload_file" do
57
172
  it "raises the filesystem error when the local source does not exist" do
58
173
  Dir.mktmpdir do |dir|
@@ -61,6 +176,82 @@ RSpec.describe Files::File, "transfers" do
61
176
  expect { described_class.upload_file(missing_path, "report.bin") }.to raise_error(Errno::ENOENT)
62
177
  end
63
178
  end
179
+
180
+ it "uploads an empty file as one empty part" do
181
+ requests = []
182
+ with_local_file("") { |path| described_class.upload_file(path, "report.bin", { client: upload_api(requests, partsize: 4) }) }
183
+
184
+ expect(parts_sent(requests)).to eq([ [ 1, "" ] ])
185
+ expect(requests.last[0..1]).to match([ :end, hash_including("action" => "end", "size" => "0") ])
186
+ end
187
+
188
+ it "reports progress with the local file's size as its total, whatever size its planning parameters carry, and sends progress in no request" do
189
+ requests = []
190
+ reports = []
191
+ with_local_file("abcdefghij") do |path|
192
+ described_class.upload_file(path, "report.bin", { client: upload_api(requests, partsize: 4) }, params: { size: 999 }, progress: ->(report) { reports << report })
193
+ end
194
+
195
+ expect(requests.first[1]["size"]).to eq("999")
196
+ expect(reports).to eq([
197
+ { total_bytes: 10, acknowledged_bytes: 4, part_number: 1, part_bytes: 4 },
198
+ { total_bytes: 10, acknowledged_bytes: 8, part_number: 2, part_bytes: 4 },
199
+ { total_bytes: 10, acknowledged_bytes: 10, part_number: 3, part_bytes: 2 }
200
+ ]
201
+ )
202
+ requests.each do |_kind, sent, *, headers|
203
+ expect(headers.keys.map(&:downcase)).not_to include("progress")
204
+ expect(sent.keys).not_to include("progress") if sent.is_a?(Hash)
205
+ end
206
+ end
207
+
208
+ it "refuses a part answered with a non-success status it returns, before reporting or ending the upload, when progress is given" do
209
+ requests = []
210
+ reports = []
211
+ client = upload_api(requests, partsize: 4, part_response: ->(_number) { [ 302, {}, "" ] })
212
+
213
+ with_local_file("abcdefghij") do |path|
214
+ expect { described_class.upload_file(path, "report.bin", { client: client }, progress: ->(report) { reports << report }) }
215
+ .to raise_error(Files::APIError, "Transfer request failed") { |error|
216
+ expect(error.http_status).to eq(302)
217
+ expect(error.full_message).not_to include("storage.example.test")
218
+ }
219
+ end
220
+ expect(reports).to be_empty
221
+ expect(requests.map(&:first)).to eq(%i[begin part])
222
+ end
223
+
224
+ it "uploads as before when a part's non-success status is returned and no progress is given" do
225
+ requests = []
226
+ client = upload_api(requests, partsize: 4, part_response: ->(_number) { [ 302, {}, "" ] })
227
+
228
+ with_local_file("abcdefghij") { |path| described_class.upload_file(path, "report.bin", { client: client }) }
229
+
230
+ expect(requests.map(&:first)).to eq(%i[begin part begin part begin part end])
231
+ end
232
+
233
+ it "stops at a part answered 500, reporting nothing and not ending the upload" do
234
+ requests = []
235
+ reports = []
236
+ client = upload_api(requests, partsize: 4, part_response: ->(_number) { [ 500, {}, "" ] })
237
+
238
+ with_local_file("abcdefghij") do |path|
239
+ expect { described_class.upload_file(path, "report.bin", { client: client }, progress: ->(report) { reports << report }) }.to raise_error(Files::APIConnectionError)
240
+ end
241
+ expect(reports).to be_empty
242
+ expect(requests.map(&:first)).to eq(%i[begin part])
243
+ end
244
+
245
+ it "ends the upload where its progress callback raises, with nothing planned, sent or ended after it, and closes its own file" do
246
+ requests = []
247
+ cancelled = StandardError.new("cancelled")
248
+ with_local_file("abcdefghij") do |path|
249
+ expect { described_class.upload_file(path, "report.bin", { client: upload_api(requests, partsize: 4) }, progress: ->(_report) { raise cancelled }) }
250
+ .to raise_error(StandardError) { |error| expect(error).to equal(cancelled) }
251
+ expect(ObjectSpace.each_object(::File).select { |file| !file.closed? && file.path == path }).to be_empty
252
+ end
253
+ expect(requests.map(&:first)).to eq(%i[begin part])
254
+ end
64
255
  end
65
256
 
66
257
  describe "#download_content" do
@@ -16,7 +16,7 @@ RSpec.describe Files::File do
16
16
  "local/path/to/file.txt",
17
17
  "_/RemoteServers/42/file.txt",
18
18
  {},
19
- params: {}
19
+ params: {}, progress: nil
20
20
  )
21
21
 
22
22
  described_class.upload_to_remote_server("local/path/to/file.txt", 42)
data/spec/spec_helper.rb CHANGED
@@ -16,7 +16,15 @@ module MockServerFixture
16
16
  API_KEY = "synthetic-mock-server-api-key".freeze
17
17
  PARENT = Pathname.new("files_regional_worker")
18
18
 
19
+ # The shared API scenarios contract an example names with its :api_scenarios metadata, the definition's SHA-256.
20
+ API_SCENARIOS = { "format" => "files-api-scenarios", "format_version" => 1 }.freeze
21
+
19
22
  def self.origin
23
+ simulator.first
24
+ end
25
+
26
+ # The simulator's origin and the readiness report that showed it is the expected simulator.
27
+ def self.simulator
20
28
  values = %w[FILES_MOCK_SERVER_HOST FILES_MOCK_SERVER_PORT FILES_MOCK_SCHEMA_SHA256].to_h { |name| [ name, ENV[name].to_s.strip ] }
21
29
  missing = values.select { |_, value| value.empty? }.keys
22
30
  raise "Set #{missing.join(", ")} for the Files.com mock server's simulator; these examples never use Files.com." if missing.any?
@@ -26,11 +34,95 @@ module MockServerFixture
26
34
  raise "FILES_MOCK_SERVER_PORT must be a port number, not #{port.inspect}." unless port.match?(/\A\d{1,5}\z/) && port.to_i.between?(1, 65_535)
27
35
 
28
36
  origin = "http://#{host == "::1" ? "[::1]" : host}:#{port}"
37
+ report = ready(origin)
38
+ expect_simulator(origin, report, schema)
39
+ [ origin, report ]
40
+ end
41
+
42
+ def self.expect_simulator(origin, report, schema = ENV["FILES_MOCK_SCHEMA_SHA256"].to_s.strip)
29
43
  expected = { "status" => "ready", "mode" => "simulation", "contract_version" => 3, "schema_sha256" => schema }
30
- reported = ready(origin).slice(*expected.keys)
44
+ reported = report.slice(*expected.keys)
31
45
  raise "#{origin} is not the expected simulator: it reports #{reported}, not #{expected}." unless reported == expected
46
+ end
47
+
48
+ # This run's hold on the shared daemon. @daemon is the origin and instance the run admitted first. @owned is the
49
+ # epoch this run made, { origin:, instance:, epoch:, mode:, identity: } (mode :default and identity nil after {}),
50
+ # set only once the reset's own answer and a fresh readiness report both showed it. @failed is the first reset that
51
+ # failed or could not be verified: once it is set, nothing resets the daemon again and the suite fails. The runtime
52
+ # must give the run a fresh daemon of its own: epoch 0 shows the boot profile, not that the daemon is empty or that
53
+ # nothing else uses it.
54
+ #
55
+ # Admits an example at the daemon `report` came from, before its journal baseline or any folder, and returns the
56
+ # contract its journal checks join, or nil. An example naming the API scenarios contract (definition_sha256) reuses
57
+ # an epoch this run made under that identity, or else resets the daemon into it, only on the instance first admitted
58
+ # and only from the boot epoch or a default epoch this run made. Any other example runs as before and sends nothing
59
+ # more, unless the daemon serves the contract: an epoch this run made is first returned to the default, and any
60
+ # other is refused. Nothing is reset for a refused example.
61
+ def self.admit(origin, report, definition_sha256)
62
+ # Once a reset failed or could not be verified, no example is admitted: nothing resets the daemon or makes folders.
63
+ raise_failed_transition
64
+ instance, epoch, active = report["instance"], report["epoch"], report.dig("profile", "api_scenarios")
65
+ @daemon ||= [ origin, instance ]
66
+ replaced = @daemon != [ origin, instance ]
67
+ @owned = nil if replaced || (@owned && [ @owned[:epoch], @owned[:identity] ] != [ epoch, active ])
32
68
 
33
- origin
69
+ if definition_sha256
70
+ raise "#{origin} now answers as instance #{instance.inspect}, not #{@daemon.last.inspect}, the daemon this run admitted first; nothing was reset." if replaced
71
+
72
+ wanted = API_SCENARIOS.merge("definition_sha256" => definition_sha256)
73
+ return @owned if @owned && @owned[:identity] == wanted
74
+
75
+ raise "#{origin} is at epoch #{epoch.inspect} serving #{active.inspect}, which this run did not make; the API scenarios examples reset only the boot epoch or a default epoch this run made." unless (epoch == 0 && active.nil? && @owned.nil?) || (@owned && @owned[:mode] == :default)
76
+
77
+ transition(origin, instance, epoch, { "profile" => { "api_scenarios" => wanted } }, wanted)
78
+ elsif active
79
+ raise "#{origin} serves the API scenarios contract #{active.inspect} at epoch #{epoch.inspect}, which this run did not select; this example needs the default profile." unless @owned
80
+
81
+ transition(origin, instance, epoch, {}, nil)
82
+ nil
83
+ end
84
+ end
85
+
86
+ # Resets the daemon from `epoch` with `body` and verifies the next epoch on the same instance: first in the reset's
87
+ # own answer ({} is answered with the epoch alone; the contract adds the instance and its identity), then in a fresh
88
+ # readiness report. {} chooses the boot-default profile and an empty state; it restores nothing that came before. A
89
+ # reset attempted grants nothing: @owned is set only once both checks pass, and the first failure latches.
90
+ def self.transition(origin, instance, epoch, body, identity)
91
+ @owned = nil
92
+ response = reset(origin, body)
93
+ answer = JSON.parse(response.body)
94
+ expected = { "epoch" => epoch + 1 }.merge(identity ? { "instance" => instance, "api_scenarios" => identity } : {})
95
+ raise "the reset answered #{response.code} #{answer.inspect}, not 200 #{expected}" unless response.code == "200" && answer == expected
96
+
97
+ report = ready(origin)
98
+ expect_simulator(origin, report)
99
+ shown = [ report["instance"], report["epoch"], report.dig("profile", "api_scenarios") ]
100
+ raise "readiness after the reset shows instance, epoch and api_scenarios #{shown.inspect}, not #{[ instance, epoch + 1, identity ].inspect}" unless shown == [ instance, epoch + 1, identity ]
101
+
102
+ @owned = { origin: origin, instance: instance, epoch: epoch + 1, mode: identity ? :api_scenarios : :default, identity: identity }
103
+ rescue StandardError => e
104
+ @failed ||= "resetting #{origin} from epoch #{epoch.inspect} with #{JSON.generate(body)}: #{e.message}"
105
+ raise_failed_transition
106
+ end
107
+
108
+ def self.raise_failed_transition
109
+ raise "The shared daemon's API scenarios contract could not be entered or left (#{@failed}); this run resets it no further." if @failed
110
+ end
111
+
112
+ # After the suite: a failed transition fails the suite again, before anything else. Only an epoch this run made under
113
+ # the API scenarios contract is then returned to the default, once readiness still shows exactly that epoch; a run
114
+ # that made none sends nothing.
115
+ def self.finish
116
+ raise_failed_transition
117
+ return unless @owned && @owned[:mode] == :api_scenarios
118
+
119
+ origin, instance, epoch, identity = @owned.values_at(:origin, :instance, :epoch, :identity)
120
+ report = ready(origin)
121
+ expect_simulator(origin, report)
122
+ shown = [ report["instance"], report["epoch"], report.dig("profile", "api_scenarios") ]
123
+ raise "#{origin} now shows instance, epoch and api_scenarios #{shown.inspect}, not #{[ instance, epoch, identity ].inspect}, the epoch this run made; it is left as it is." unless shown == [ instance, epoch, identity ]
124
+
125
+ transition(origin, instance, epoch, {}, nil)
34
126
  end
35
127
 
36
128
  # Waits up to READY_TIMEOUT for something to listen at origin. Whatever answers first decides: a
@@ -57,18 +149,31 @@ module MockServerFixture
57
149
  Net::HTTP.start(uri.hostname, uri.port, open_timeout: 5, read_timeout: 10) { |http| http.get(uri.request_uri) }
58
150
  end
59
151
 
60
- def self.journal(origin)
152
+ def self.reset(origin, body)
153
+ uri = URI("#{origin}/__files_mock/v1/reset")
154
+ Net::HTTP.start(uri.hostname, uri.port, open_timeout: 5, read_timeout: 10) { |http| http.post(uri.request_uri, JSON.generate(body), "Content-Type" => "application/json") }
155
+ end
156
+
157
+ # The journal's entries. Under the API scenarios contract the journal must also show the contract's origin, epoch,
158
+ # instance and identity, and that it dropped nothing, so its entries are the ones that epoch served.
159
+ def self.journal(origin, contract = nil)
61
160
  journal = JSON.parse(control(origin, "journal").body)
62
161
  raise "The simulator's journal is incomplete, so it cannot show which requests the SDK sent." unless journal["complete"] == true
63
162
 
163
+ if contract
164
+ shown = [ origin, *journal.values_at("epoch", "instance", "api_scenarios", "dropped") ]
165
+ expected = [ contract[:origin], contract[:epoch], contract[:instance], contract[:identity], 0 ]
166
+ raise "The simulator's journal shows origin, epoch, instance, api_scenarios and dropped #{shown.inspect}, not this example's #{expected.inspect}." unless shown == expected
167
+ end
168
+
64
169
  journal.fetch("entries")
65
170
  end
66
171
 
67
172
  # The API requests the example sent: each carried the same synthetic API key (the journal numbers keys
68
173
  # and never records them), no session, and the Ruby SDK's User-Agent. Transfers to the upload and
69
174
  # download URLs the simulator issued carry no credential, as presigned URLs do not.
70
- def self.check_requests(origin, after)
71
- entries = journal(origin).select { |entry| entry.fetch("seq") > after && entry.fetch("path").start_with?("/api/rest/v1/") }
175
+ def self.check_requests(origin, after, contract = nil)
176
+ entries = journal(origin, contract).select { |entry| entry.fetch("seq") > after && entry.fetch("path").start_with?("/api/rest/v1/") }
72
177
  raise "No API request reached the simulator." if entries.empty?
73
178
 
74
179
  keys = entries.map { |entry| entry.dig("credentials", "api_key") }.uniq
@@ -118,9 +223,10 @@ RSpec.configure do |config|
118
223
  config.around(:example, :with_test_folder) do |all|
119
224
  base_url = Files.base_url
120
225
  language = Files.language
121
- origin = MockServerFixture.origin
226
+ origin, readiness = MockServerFixture.simulator
227
+ contract = MockServerFixture.admit(origin, readiness, all.metadata[:api_scenarios])
122
228
  Files.base_url = origin
123
- after = MockServerFixture.journal(origin).map { |entry| entry.fetch("seq") }.max || 0
229
+ after = MockServerFixture.journal(origin, contract).map { |entry| entry.fetch("seq") }.max || 0
124
230
  @api_key = MockServerFixture::API_KEY
125
231
  @test_folder = MockServerFixture::PARENT.join(SecureRandom.uuid)
126
232
  sibling = MockServerFixture::PARENT.join("#{@test_folder.basename}-sibling")
@@ -135,7 +241,7 @@ RSpec.configure do |config|
135
241
  begin
136
242
  if created&.any?
137
243
  MockServerFixture.remove(created, @test_folder, sibling, { api_key: @api_key })
138
- MockServerFixture.check_requests(origin, after)
244
+ MockServerFixture.check_requests(origin, after, contract)
139
245
  end
140
246
  ensure
141
247
  Files.base_url = base_url
@@ -145,6 +251,10 @@ RSpec.configure do |config|
145
251
  raise "The example left Files.language set to #{left_language.inspect}." unless left_language == language
146
252
  end
147
253
 
254
+ # A failed transition fails the suite before anything else; only an epoch this run made under the API scenarios
255
+ # contract is then returned to the default. A run that made none sends nothing.
256
+ config.after(:suite) { MockServerFixture.finish }
257
+
148
258
  config.shared_context_metadata_behavior = :apply_to_host_groups
149
259
  config.fail_if_no_examples = true
150
260
  config.disable_monkey_patching!
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: files.com
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.794
4
+ version: 1.1.796
5
5
  platform: ruby
6
6
  authors:
7
7
  - files.com
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
  date: 2026-10-22 00:00:00.000000000 Z
@@ -483,6 +483,7 @@ files:
483
483
  - shared/path_comparison.json
484
484
  - shared/path_comparison.md
485
485
  - shared/url_test_data.json
486
+ - spec/api_scenarios_spec.rb
486
487
  - spec/decimal_contract_spec.rb
487
488
  - spec/language_spec.rb
488
489
  - spec/lib/api_client_spec.rb
@@ -506,7 +507,7 @@ homepage: https://www.files.com
506
507
  licenses:
507
508
  - MIT
508
509
  metadata: {}
509
- post_install_message:
510
+ post_install_message:
510
511
  rdoc_options: []
511
512
  require_paths:
512
513
  - lib
@@ -522,7 +523,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
522
523
  version: '0'
523
524
  requirements: []
524
525
  rubygems_version: 3.5.20
525
- signing_key:
526
+ signing_key:
526
527
  specification_version: 4
527
528
  summary: Files.com Ruby client.
528
529
  test_files: []