scanii-ruby 1.0.1 → 1.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
- data/CHANGELOG.md +53 -0
- data/README.md +18 -3
- data/lib/scanii/client.rb +173 -20
- data/lib/scanii/multipart.rb +82 -27
- data/lib/scanii/processing_result.rb +12 -2
- data/lib/scanii/trace_event.rb +20 -0
- data/lib/scanii/trace_result.rb +33 -0
- data/lib/scanii/version.rb +1 -1
- data/lib/scanii.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2b4cc56c7cdba5d949e11dd004f7dbaa29ada9dab5e7b64173f5a523dc4f4e51
|
|
4
|
+
data.tar.gz: 37b10fb0ee6aa5dfbabf6b9c097fbf837f297fd557e27f0dc97e49278ed355d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 97c6569f83a40beb529c5d4a2189a57443201d17eafc9be2c75e693316ddf6d98e3306a9819d20346c6f573b0d3fbb182eea3493ad8269f4d8bdeef640b79096
|
|
7
|
+
data.tar.gz: 70cc50536847e7f83fce477098f6feb780ce6bf9ed3571d1ed92b0f8cd6b929eb5dd2fd2bd4cbdc5afc9d4b989f3df07ffdb86ae901e698e66de2884c95811c6
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,59 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `scanii-ruby` are documented here. Versions follow [SemVer](https://semver.org).
|
|
4
4
|
|
|
5
|
+
## [1.2.0] — v2.2 surface
|
|
6
|
+
|
|
7
|
+
### New API
|
|
8
|
+
|
|
9
|
+
- `Scanii::Client#retrieve_trace(id)` → `Scanii::TraceResult` or `nil` — retrieves the
|
|
10
|
+
ordered processing event trace for a scan via `GET /files/{id}/trace`. Returns `nil` on 404
|
|
11
|
+
(no trace for that id). v2.2 preview surface; API shape may shift before marked stable.
|
|
12
|
+
- `Scanii::Client#process_from_url(location, callback: nil, metadata: nil)` →
|
|
13
|
+
`Scanii::ProcessingResult` — submits a URL for synchronous scanning via `POST /files` with
|
|
14
|
+
`location` as a multipart/form-data field. Distinct from `fetch`, which submits to
|
|
15
|
+
`/files/fetch` for asynchronous server-side fetching. `location` must be a String URL.
|
|
16
|
+
v2.2 preview surface.
|
|
17
|
+
- `Scanii::TraceResult` — new result class with `id`, `events`, `request_id`, `host_id`,
|
|
18
|
+
`raw_response`.
|
|
19
|
+
- `Scanii::TraceEvent` — new model with `timestamp` (String) and `message` (String).
|
|
20
|
+
|
|
21
|
+
### Deprecations
|
|
22
|
+
|
|
23
|
+
- `Scanii::ProcessingResult#error` — deprecated. The server never populates this field on
|
|
24
|
+
successful responses; errors arrive as non-2xx HTTP responses that raise `Scanii::Error`
|
|
25
|
+
subclasses. The field still exists and emits a runtime `warn` on access. Will be removed
|
|
26
|
+
in a future major version.
|
|
27
|
+
|
|
28
|
+
## 1.1.0 — Streaming standardization
|
|
29
|
+
|
|
30
|
+
Adds stream-based `process` and `process_async` methods, aligning scanii-ruby with the
|
|
31
|
+
cross-SDK streaming standard. File content is now truly streamed to the socket via
|
|
32
|
+
`Net::HTTP#body_stream=` rather than buffered into a single String.
|
|
33
|
+
|
|
34
|
+
### New API
|
|
35
|
+
|
|
36
|
+
- `Scanii::Client#process(io, filename:, content_type: nil, metadata: nil, callback: nil)` →
|
|
37
|
+
`Scanii::ProcessingResult` — accepts any IO-like object (anything responding to `read(n)`).
|
|
38
|
+
Both `File` (opened with `File.open(path, "rb")`) and `StringIO` work.
|
|
39
|
+
- `Scanii::Client#process_file(path, metadata: nil, callback: nil)` →
|
|
40
|
+
`Scanii::ProcessingResult` — convenience wrapper that opens the file in binary mode and
|
|
41
|
+
delegates to `process`. This is the replacement for the old `process(path, ...)` form.
|
|
42
|
+
- Same shapes for `process_async` / `process_async_file`.
|
|
43
|
+
|
|
44
|
+
### Deprecations
|
|
45
|
+
|
|
46
|
+
- `process(path_string, ...)` — passing a String path to `process` is deprecated; use
|
|
47
|
+
`process_file(path)` instead. The old form still works and emits a runtime `warn`. Will be
|
|
48
|
+
removed in a future major version.
|
|
49
|
+
- `process_async(path_string, ...)` — same; use `process_async_file(path)`. Will be removed
|
|
50
|
+
in a future major version.
|
|
51
|
+
|
|
52
|
+
### Internals
|
|
53
|
+
|
|
54
|
+
- `Scanii::Multipart.stream_encode` replaces the old `encode`. Returns a `[ChainedIO,
|
|
55
|
+
content_type, content_length]` triple. `ChainedIO` reads prologue → caller IO → epilogue
|
|
56
|
+
without ever buffering the full body.
|
|
57
|
+
|
|
5
58
|
## 1.0.1 — Release infrastructure fix
|
|
6
59
|
|
|
7
60
|
Wires up `bundler/gem_tasks` in the Rakefile so `bundle exec rake release` (invoked by `rubygems/release-gem@v1`) resolves correctly. v1.0.0 was tagged but never published to RubyGems because the release workflow failed at the `rake release` task lookup; v1.0.1 is functionally identical to that tag. No SDK behavior changes.
|
data/README.md
CHANGED
|
@@ -30,12 +30,23 @@ Targets Ruby 3.4+. Zero runtime dependencies.
|
|
|
30
30
|
|
|
31
31
|
## Quickstart
|
|
32
32
|
|
|
33
|
+
Scan a file from disk:
|
|
34
|
+
|
|
33
35
|
```ruby
|
|
34
36
|
require "scanii"
|
|
35
37
|
|
|
36
38
|
client = Scanii::Client.new(key: "your-key", secret: "your-secret")
|
|
37
39
|
|
|
38
|
-
result = client.
|
|
40
|
+
result = client.process_file("./file.pdf")
|
|
41
|
+
puts "findings: #{result.findings.inspect}"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Scan content already in memory (no temp file needed):
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
require "stringio"
|
|
48
|
+
|
|
49
|
+
result = client.process(StringIO.new(bytes), filename: "upload.bin")
|
|
39
50
|
puts "findings: #{result.findings.inspect}"
|
|
40
51
|
```
|
|
41
52
|
|
|
@@ -45,10 +56,14 @@ puts "findings: #{result.findings.inspect}"
|
|
|
45
56
|
|
|
46
57
|
| Method | REST | Returns |
|
|
47
58
|
|---|---|---|
|
|
48
|
-
| `process(
|
|
49
|
-
| `
|
|
59
|
+
| `process(io, filename:, content_type:, metadata:, callback:)` | `POST /files` | `Scanii::ProcessingResult` |
|
|
60
|
+
| `process_file(path, metadata:, callback:)` | `POST /files` | `Scanii::ProcessingResult` |
|
|
61
|
+
| `process_async(io, filename:, content_type:, metadata:, callback:)` | `POST /files/async` | `Scanii::PendingResult` |
|
|
62
|
+
| `process_async_file(path, metadata:, callback:)` | `POST /files/async` | `Scanii::PendingResult` |
|
|
63
|
+
| `process_from_url(location, callback:, metadata:)` | `POST /files` | `Scanii::ProcessingResult` (v2.2 preview) |
|
|
50
64
|
| `fetch(url, metadata:, callback:)` | `POST /files/fetch` | `Scanii::PendingResult` |
|
|
51
65
|
| `retrieve(id)` | `GET /files/{id}` | `Scanii::ProcessingResult` |
|
|
66
|
+
| `retrieve_trace(id)` | `GET /files/{id}/trace` | `Scanii::TraceResult` or `nil` (v2.2 preview) |
|
|
52
67
|
| `ping` | `GET /ping` | `true` |
|
|
53
68
|
| `create_auth_token(timeout_seconds)` | `POST /auth/tokens` | `Scanii::AuthToken` |
|
|
54
69
|
| `retrieve_auth_token(id)` | `GET /auth/tokens/{id}` | `Scanii::AuthToken` |
|
data/lib/scanii/client.rb
CHANGED
|
@@ -13,10 +13,13 @@ module Scanii
|
|
|
13
13
|
#
|
|
14
14
|
# @see https://scanii.github.io/openapi/v22/
|
|
15
15
|
#
|
|
16
|
-
# @example
|
|
16
|
+
# @example Scan a file from disk
|
|
17
17
|
# client = Scanii::Client.new(key: "your-key", secret: "your-secret")
|
|
18
|
-
# result = client.
|
|
18
|
+
# result = client.process_file("./file.pdf")
|
|
19
19
|
# puts result.findings # [] when clean
|
|
20
|
+
#
|
|
21
|
+
# @example Scan content already in memory
|
|
22
|
+
# result = client.process(StringIO.new(bytes), filename: "upload.bin")
|
|
20
23
|
class Client
|
|
21
24
|
DEFAULT_ENDPOINT = "https://api.scanii.com".freeze
|
|
22
25
|
DEFAULT_TIMEOUT = 60
|
|
@@ -44,34 +47,111 @@ module Scanii
|
|
|
44
47
|
@user_agent = user_agent && !user_agent.empty? ? "#{user_agent} #{USER_AGENT}" : USER_AGENT
|
|
45
48
|
end
|
|
46
49
|
|
|
47
|
-
# Submit
|
|
50
|
+
# Submit an IO-like object for synchronous scanning.
|
|
51
|
+
#
|
|
52
|
+
# +io+ is duck-typed: anything responding to +read(n)+ returning a String.
|
|
53
|
+
# Both +File+ (opened with +File.open(path, "rb")+) and +StringIO+ work.
|
|
54
|
+
# The body is streamed to the socket; file content is never fully buffered.
|
|
55
|
+
#
|
|
56
|
+
# Passing a String path is deprecated — use {#process_file} instead.
|
|
57
|
+
#
|
|
58
|
+
# @overload process(io, filename:, content_type: nil, metadata: nil, callback: nil)
|
|
59
|
+
# @param io [#read] IO-like object
|
|
60
|
+
# @param filename [String] filename sent in the multipart part
|
|
61
|
+
# @param content_type [String, nil] content-type of the file part; guessed from filename when nil
|
|
62
|
+
# @param metadata [Hash{String=>String}, nil] arbitrary key/value pairs attached to the result
|
|
63
|
+
# @param callback [String, nil] URL to POST the result to on completion
|
|
48
64
|
#
|
|
49
65
|
# @see https://scanii.github.io/openapi/v22/ POST /files
|
|
50
66
|
# @return [Scanii::ProcessingResult]
|
|
51
|
-
def process(
|
|
52
|
-
|
|
67
|
+
def process(first_arg, filename: nil, content_type: nil, metadata: nil, callback: nil)
|
|
68
|
+
if first_arg.is_a?(String)
|
|
69
|
+
# @deprecated Use {#process_file} instead. Will be removed in a future major version.
|
|
70
|
+
warn "[DEPRECATION] `Scanii::Client#process(path)` is deprecated; " \
|
|
71
|
+
"use `process_file(path)` instead. Will be removed in a future major version."
|
|
72
|
+
return process_file(first_arg, metadata: metadata, callback: callback)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
raise ArgumentError, "io must respond to read" unless first_arg.respond_to?(:read)
|
|
76
|
+
raise ArgumentError, "filename: is required" if filename.nil? || filename.to_s.empty?
|
|
77
|
+
|
|
53
78
|
fields = build_text_fields(metadata, callback)
|
|
54
|
-
|
|
55
|
-
status, resp_body, headers = post("/files",
|
|
79
|
+
stream, ct, length = Multipart.stream_encode(fields, first_arg, filename.to_s, content_type)
|
|
80
|
+
status, resp_body, headers = post("/files", body_stream: stream, content_type: ct,
|
|
81
|
+
content_length: length)
|
|
56
82
|
raise_for_status(status, resp_body, headers) unless status == 201
|
|
57
83
|
ProcessingResult.from_response(resp_body, headers)
|
|
58
84
|
end
|
|
59
85
|
|
|
60
|
-
# Submit a file for
|
|
61
|
-
#
|
|
62
|
-
#
|
|
86
|
+
# Submit a file path for synchronous scanning.
|
|
87
|
+
#
|
|
88
|
+
# Opens the file in binary mode, streams it to Scanii, and closes it.
|
|
89
|
+
# Delegates to {#process} with +filename+ set to the basename.
|
|
90
|
+
#
|
|
91
|
+
# @param file_path [String] path to the file to upload
|
|
92
|
+
# @param metadata [Hash{String=>String}, nil]
|
|
93
|
+
# @param callback [String, nil]
|
|
94
|
+
# @see https://scanii.github.io/openapi/v22/ POST /files
|
|
95
|
+
# @return [Scanii::ProcessingResult]
|
|
96
|
+
def process_file(file_path, metadata: nil, callback: nil)
|
|
97
|
+
assert_readable(file_path)
|
|
98
|
+
File.open(file_path.to_s, "rb") do |f|
|
|
99
|
+
process(f, filename: File.basename(file_path.to_s), metadata: metadata, callback: callback)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Submit an IO-like object for server-side asynchronous scanning.
|
|
104
|
+
#
|
|
105
|
+
# Returns a pending id; the final result is delivered to +callback+ (when
|
|
106
|
+
# supplied) or fetched via {#retrieve}.
|
|
107
|
+
#
|
|
108
|
+
# Passing a String path is deprecated — use {#process_async_file} instead.
|
|
109
|
+
#
|
|
110
|
+
# @overload process_async(io, filename:, content_type: nil, metadata: nil, callback: nil)
|
|
111
|
+
# @param io [#read] IO-like object
|
|
112
|
+
# @param filename [String] filename sent in the multipart part
|
|
113
|
+
# @param content_type [String, nil]
|
|
114
|
+
# @param metadata [Hash{String=>String}, nil]
|
|
115
|
+
# @param callback [String, nil]
|
|
63
116
|
#
|
|
64
117
|
# @see https://scanii.github.io/openapi/v22/ POST /files/async
|
|
65
118
|
# @return [Scanii::PendingResult]
|
|
66
|
-
def process_async(
|
|
67
|
-
|
|
119
|
+
def process_async(first_arg, filename: nil, content_type: nil, metadata: nil, callback: nil)
|
|
120
|
+
if first_arg.is_a?(String)
|
|
121
|
+
# @deprecated Use {#process_async_file} instead. Will be removed in a future major version.
|
|
122
|
+
warn "[DEPRECATION] `Scanii::Client#process_async(path)` is deprecated; " \
|
|
123
|
+
"use `process_async_file(path)` instead. Will be removed in a future major version."
|
|
124
|
+
return process_async_file(first_arg, metadata: metadata, callback: callback)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
raise ArgumentError, "io must respond to read" unless first_arg.respond_to?(:read)
|
|
128
|
+
raise ArgumentError, "filename: is required" if filename.nil? || filename.to_s.empty?
|
|
129
|
+
|
|
68
130
|
fields = build_text_fields(metadata, callback)
|
|
69
|
-
|
|
70
|
-
status, resp_body, headers = post("/files/async",
|
|
131
|
+
stream, ct, length = Multipart.stream_encode(fields, first_arg, filename.to_s, content_type)
|
|
132
|
+
status, resp_body, headers = post("/files/async", body_stream: stream, content_type: ct,
|
|
133
|
+
content_length: length)
|
|
71
134
|
raise_for_status(status, resp_body, headers) unless status == 202
|
|
72
135
|
PendingResult.from_response(resp_body, headers)
|
|
73
136
|
end
|
|
74
137
|
|
|
138
|
+
# Submit a file path for server-side asynchronous scanning.
|
|
139
|
+
#
|
|
140
|
+
# Opens the file in binary mode and delegates to {#process_async}.
|
|
141
|
+
#
|
|
142
|
+
# @param file_path [String] path to the file to upload
|
|
143
|
+
# @param metadata [Hash{String=>String}, nil]
|
|
144
|
+
# @param callback [String, nil]
|
|
145
|
+
# @see https://scanii.github.io/openapi/v22/ POST /files/async
|
|
146
|
+
# @return [Scanii::PendingResult]
|
|
147
|
+
def process_async_file(file_path, metadata: nil, callback: nil)
|
|
148
|
+
assert_readable(file_path)
|
|
149
|
+
File.open(file_path.to_s, "rb") do |f|
|
|
150
|
+
process_async(f, filename: File.basename(file_path.to_s), metadata: metadata,
|
|
151
|
+
callback: callback)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
75
155
|
# Ask Scanii to download a remote URL and scan it asynchronously.
|
|
76
156
|
#
|
|
77
157
|
# @see https://scanii.github.io/openapi/v22/ POST /files/fetch
|
|
@@ -104,6 +184,70 @@ module Scanii
|
|
|
104
184
|
ProcessingResult.from_response(resp_body, headers)
|
|
105
185
|
end
|
|
106
186
|
|
|
187
|
+
# Retrieve the processing event trace for a previously submitted scan.
|
|
188
|
+
#
|
|
189
|
+
# Returns nil when no trace exists for the given id (HTTP 404).
|
|
190
|
+
#
|
|
191
|
+
# This is a v2.2 preview surface; the API shape may shift before it is
|
|
192
|
+
# marked stable.
|
|
193
|
+
#
|
|
194
|
+
# @param id [String] processing id returned by process or process_file
|
|
195
|
+
# @see https://scanii.github.io/openapi/v22/ GET /files/{id}/trace
|
|
196
|
+
# @return [Scanii::TraceResult, nil]
|
|
197
|
+
def retrieve_trace(id)
|
|
198
|
+
raise ArgumentError, "id must not be empty" if id.nil? || id.empty?
|
|
199
|
+
|
|
200
|
+
status, resp_body, headers = request("GET", "/files/#{url_encode(id)}/trace")
|
|
201
|
+
return nil if status == 404
|
|
202
|
+
|
|
203
|
+
raise_for_status(status, resp_body, headers) unless status == 200
|
|
204
|
+
TraceResult.from_response(resp_body, headers)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Submit a remote URL for synchronous scanning.
|
|
208
|
+
#
|
|
209
|
+
# Sends the URL as a +location+ field in a multipart/form-data POST to
|
|
210
|
+
# +/files+. The Scanii server fetches and scans the URL synchronously and
|
|
211
|
+
# returns a ProcessingResult. This is distinct from {#fetch}, which submits
|
|
212
|
+
# to +/files/fetch+ for asynchronous server-side fetching.
|
|
213
|
+
#
|
|
214
|
+
# +location+ must be a String URL. This matches the existing {#fetch}
|
|
215
|
+
# String-URL convention and the Java reference (processFromUrl(String)).
|
|
216
|
+
#
|
|
217
|
+
# This is a v2.2 preview surface; the API shape may shift before it is
|
|
218
|
+
# marked stable.
|
|
219
|
+
#
|
|
220
|
+
# @param location [String] URL of the content to scan
|
|
221
|
+
# @param callback [String, nil] URL to POST the result to on completion
|
|
222
|
+
# @param metadata [Hash{String=>String}, nil] arbitrary key/value pairs attached to the result
|
|
223
|
+
# @see https://scanii.github.io/openapi/v22/ POST /files
|
|
224
|
+
# @return [Scanii::ProcessingResult]
|
|
225
|
+
def process_from_url(location, callback: nil, metadata: nil)
|
|
226
|
+
raise ArgumentError, "location must not be empty" if location.nil? || location.to_s.empty?
|
|
227
|
+
|
|
228
|
+
fields = build_text_fields(metadata, callback)
|
|
229
|
+
fields["location"] = location.to_s
|
|
230
|
+
|
|
231
|
+
boundary = Multipart.make_boundary
|
|
232
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
233
|
+
fields.each do |name, value|
|
|
234
|
+
body << "--#{boundary}\r\n".b
|
|
235
|
+
body << "Content-Disposition: form-data; name=\"#{name}\"\r\n".b
|
|
236
|
+
body << "Content-Type: text/plain; charset=UTF-8\r\n\r\n".b
|
|
237
|
+
body << value.to_s.b
|
|
238
|
+
body << "\r\n".b
|
|
239
|
+
end
|
|
240
|
+
body << "--#{boundary}--\r\n".b
|
|
241
|
+
|
|
242
|
+
status, resp_body, headers = post(
|
|
243
|
+
"/files",
|
|
244
|
+
body: body,
|
|
245
|
+
content_type: Multipart.make_content_type(boundary)
|
|
246
|
+
)
|
|
247
|
+
raise_for_status(status, resp_body, headers) unless status == 201
|
|
248
|
+
ProcessingResult.from_response(resp_body, headers)
|
|
249
|
+
end
|
|
250
|
+
|
|
107
251
|
# Verify that the configured credentials reach the API.
|
|
108
252
|
#
|
|
109
253
|
# @see https://scanii.github.io/openapi/v22/ GET /ping
|
|
@@ -190,14 +334,16 @@ module Scanii
|
|
|
190
334
|
fields
|
|
191
335
|
end
|
|
192
336
|
|
|
193
|
-
def post(path, body
|
|
194
|
-
request("POST", path, body: body, content_type: content_type
|
|
337
|
+
def post(path, body: nil, content_type: nil, body_stream: nil, content_length: nil)
|
|
338
|
+
request("POST", path, body: body, content_type: content_type,
|
|
339
|
+
body_stream: body_stream, content_length: content_length)
|
|
195
340
|
end
|
|
196
341
|
|
|
197
|
-
def request(method, path, body: nil, content_type: nil)
|
|
342
|
+
def request(method, path, body: nil, content_type: nil, body_stream: nil, content_length: nil)
|
|
198
343
|
uri = URI.parse("#{@base_uri}#{path}")
|
|
199
344
|
|
|
200
|
-
req = build_request(method, uri, body, content_type
|
|
345
|
+
req = build_request(method, uri, body: body, content_type: content_type,
|
|
346
|
+
body_stream: body_stream, content_length: content_length)
|
|
201
347
|
|
|
202
348
|
Net::HTTP.start(uri.hostname, uri.port,
|
|
203
349
|
use_ssl: uri.scheme == "https",
|
|
@@ -211,7 +357,7 @@ module Scanii
|
|
|
211
357
|
raise Scanii::Error, "transport error: #{e.class}: #{e.message}"
|
|
212
358
|
end
|
|
213
359
|
|
|
214
|
-
def build_request(method, uri, body, content_type)
|
|
360
|
+
def build_request(method, uri, body: nil, content_type: nil, body_stream: nil, content_length: nil)
|
|
215
361
|
klass = case method
|
|
216
362
|
when "GET" then Net::HTTP::Get
|
|
217
363
|
when "POST" then Net::HTTP::Post
|
|
@@ -224,7 +370,14 @@ module Scanii
|
|
|
224
370
|
req["User-Agent"] = @user_agent
|
|
225
371
|
req["Accept"] = "application/json"
|
|
226
372
|
req["Content-Type"] = content_type if content_type
|
|
227
|
-
|
|
373
|
+
|
|
374
|
+
if body_stream
|
|
375
|
+
req.body_stream = body_stream
|
|
376
|
+
req["Content-Length"] = content_length.to_s
|
|
377
|
+
elsif body
|
|
378
|
+
req.body = body
|
|
379
|
+
end
|
|
380
|
+
|
|
228
381
|
req
|
|
229
382
|
end
|
|
230
383
|
|
data/lib/scanii/multipart.rb
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
require "securerandom"
|
|
2
|
+
require "stringio"
|
|
2
3
|
|
|
3
4
|
module Scanii
|
|
4
5
|
# Hand-rolled multipart/form-data encoder (RFC 7578).
|
|
5
6
|
#
|
|
6
7
|
# Ruby's stdlib Net::HTTP does not bundle a multipart encoder; this is the
|
|
7
8
|
# smallest viable implementation that covers the Scanii POST /files payload.
|
|
8
|
-
#
|
|
9
|
-
# Body is assembled as a single binary-encoded String -- file contents are
|
|
10
|
-
# read into memory rather than streamed. This matches the PHP SDK's approach;
|
|
11
|
-
# callers scanning very large files should be aware.
|
|
12
9
|
module Multipart
|
|
13
10
|
module_function
|
|
14
11
|
|
|
@@ -22,42 +19,45 @@ module Scanii
|
|
|
22
19
|
"multipart/form-data; boundary=#{boundary}"
|
|
23
20
|
end
|
|
24
21
|
|
|
25
|
-
# Encode a multipart body
|
|
26
|
-
#
|
|
22
|
+
# Encode a multipart body as a streaming ChainedIO.
|
|
23
|
+
#
|
|
24
|
+
# Builds the RFC 7578 prologue and epilogue as binary Strings, chains them
|
|
25
|
+
# around the caller's IO, and returns the triple required for
|
|
26
|
+
# Net::HTTP body_stream= uploads. The caller's IO is never read here --
|
|
27
|
+
# only when Net::HTTP reads from the returned ChainedIO.
|
|
27
28
|
#
|
|
28
|
-
# @param fields [Hash{String=>String}] text form fields (e.g. metadata[k]
|
|
29
|
-
# @param
|
|
29
|
+
# @param fields [Hash{String=>String}] text form fields (e.g. metadata[k]=v, callback)
|
|
30
|
+
# @param io [#read, #size] IO-like object (anything responding to read(n))
|
|
31
|
+
# @param filename [String] filename for the file part
|
|
32
|
+
# @param content_type [String, nil] content-type of the file part; falls back to extension lookup
|
|
30
33
|
# @param file_field [String] name of the file form field; default "file"
|
|
31
|
-
# @return [Array(String,
|
|
32
|
-
def
|
|
34
|
+
# @return [Array(ChainedIO, String, Integer)] [body_stream, content_type_header, content_length]
|
|
35
|
+
def stream_encode(fields, io, filename, content_type = nil, file_field: "file")
|
|
33
36
|
boundary = make_boundary
|
|
37
|
+
ct = content_type || guess_content_type(filename)
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
content_type = guess_content_type(file_path)
|
|
37
|
-
file_bytes = File.binread(file_path)
|
|
38
|
-
|
|
39
|
-
body = String.new(encoding: Encoding::BINARY)
|
|
40
|
-
|
|
39
|
+
prologue = String.new(encoding: Encoding::BINARY)
|
|
41
40
|
fields.each do |name, value|
|
|
42
|
-
write_text_part(
|
|
41
|
+
write_text_part(prologue, boundary, name.to_s, value.to_s)
|
|
43
42
|
end
|
|
43
|
+
prologue << "--#{boundary}\r\n".b
|
|
44
|
+
prologue << "Content-Disposition: form-data; name=\"#{file_field}\"; filename=\"#{filename}\"\r\n".b
|
|
45
|
+
prologue << "Content-Type: #{ct}\r\n\r\n".b
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
body << "Content-Disposition: form-data; name=\"#{file_field}\"; filename=\"#{filename}\"\r\n".b
|
|
47
|
-
body << "Content-Type: #{content_type}\r\n\r\n".b
|
|
48
|
-
body << file_bytes.b
|
|
49
|
-
body << "\r\n".b
|
|
50
|
-
body << "--#{boundary}--\r\n".b
|
|
47
|
+
epilogue = "\r\n--#{boundary}--\r\n".b
|
|
51
48
|
|
|
52
|
-
|
|
49
|
+
io_size = io_remaining_bytes(io)
|
|
50
|
+
total_length = prologue.bytesize + io_size + epilogue.bytesize
|
|
51
|
+
|
|
52
|
+
[ChainedIO.new(prologue, io, epilogue), make_content_type(boundary), total_length]
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
# Best-effort content-type lookup by extension. Falls back to
|
|
55
|
+
# Best-effort content-type lookup by filename extension. Falls back to
|
|
56
56
|
# application/octet-stream. The Scanii API does not require an accurate
|
|
57
57
|
# content-type on the multipart part -- the server inspects the bytes -- so
|
|
58
58
|
# a short table is sufficient.
|
|
59
|
-
def guess_content_type(
|
|
60
|
-
ext = File.extname(
|
|
59
|
+
def guess_content_type(filename)
|
|
60
|
+
ext = File.extname(filename.to_s).delete_prefix(".").downcase
|
|
61
61
|
MIME_TYPES.fetch(ext, "application/octet-stream")
|
|
62
62
|
end
|
|
63
63
|
|
|
@@ -96,5 +96,60 @@ module Scanii
|
|
|
96
96
|
body << "\r\n".b
|
|
97
97
|
end
|
|
98
98
|
private_class_method :write_text_part
|
|
99
|
+
|
|
100
|
+
# Return the number of bytes remaining to be read from io.
|
|
101
|
+
# Requires io to respond to size (File and StringIO both do).
|
|
102
|
+
def io_remaining_bytes(io)
|
|
103
|
+
if io.respond_to?(:pos) && io.respond_to?(:size)
|
|
104
|
+
io.size - io.pos
|
|
105
|
+
elsif io.respond_to?(:size)
|
|
106
|
+
io.size
|
|
107
|
+
else
|
|
108
|
+
raise ArgumentError, "io must respond to size (File and StringIO do; got #{io.class})"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
private_class_method :io_remaining_bytes
|
|
112
|
+
|
|
113
|
+
# Reads prologue_str, then io, then epilogue_str in sequence.
|
|
114
|
+
# Used as Net::HTTP body_stream for streaming multipart uploads.
|
|
115
|
+
class ChainedIO
|
|
116
|
+
def initialize(prologue, io, epilogue)
|
|
117
|
+
@parts = [StringIO.new(prologue), io, StringIO.new(epilogue)]
|
|
118
|
+
@idx = 0
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def read(length = nil, buf = nil)
|
|
122
|
+
result = length.nil? ? read_all : read_n(length)
|
|
123
|
+
return nil if result.nil?
|
|
124
|
+
|
|
125
|
+
buf ? buf.replace(result) : result
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def read_all
|
|
131
|
+
result = String.new(encoding: Encoding::BINARY)
|
|
132
|
+
@parts[@idx..].each do |part|
|
|
133
|
+
chunk = part.read
|
|
134
|
+
result << chunk.b if chunk
|
|
135
|
+
end
|
|
136
|
+
@idx = @parts.size
|
|
137
|
+
result
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def read_n(length)
|
|
141
|
+
result = String.new(encoding: Encoding::BINARY)
|
|
142
|
+
while result.bytesize < length && @idx < @parts.size
|
|
143
|
+
chunk = @parts[@idx].read(length - result.bytesize)
|
|
144
|
+
if chunk.nil? || chunk.empty?
|
|
145
|
+
@idx += 1
|
|
146
|
+
else
|
|
147
|
+
result << chunk.b
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
result.empty? ? nil : result
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
private_constant :ChainedIO
|
|
99
154
|
end
|
|
100
155
|
end
|
|
@@ -9,7 +9,7 @@ module Scanii
|
|
|
9
9
|
# @see https://scanii.github.io/openapi/v22/
|
|
10
10
|
class ProcessingResult
|
|
11
11
|
attr_reader :id, :findings, :checksum, :content_length, :content_type,
|
|
12
|
-
:metadata, :creation_date,
|
|
12
|
+
:metadata, :creation_date,
|
|
13
13
|
:request_id, :host_id, :resource_location, :raw_response
|
|
14
14
|
|
|
15
15
|
def initialize(id:, findings:, checksum:, content_length:, content_type:,
|
|
@@ -22,13 +22,23 @@ module Scanii
|
|
|
22
22
|
@content_type = content_type
|
|
23
23
|
@metadata = metadata
|
|
24
24
|
@creation_date = creation_date
|
|
25
|
-
@
|
|
25
|
+
@_error = error
|
|
26
26
|
@request_id = request_id
|
|
27
27
|
@host_id = host_id
|
|
28
28
|
@resource_location = resource_location
|
|
29
29
|
@raw_response = raw_response
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# @deprecated The server never populates this field on successful responses;
|
|
33
|
+
# errors arrive as non-2xx HTTP responses that raise Scanii::Error
|
|
34
|
+
# subclasses. Will be removed in a future major version.
|
|
35
|
+
def error
|
|
36
|
+
warn "[DEPRECATION] `Scanii::ProcessingResult#error` is deprecated; " \
|
|
37
|
+
"rescue Scanii::Error (and its subclasses) to handle server-side errors. " \
|
|
38
|
+
"Will be removed in a future major version."
|
|
39
|
+
@_error
|
|
40
|
+
end
|
|
41
|
+
|
|
32
42
|
def self.from_response(body, headers)
|
|
33
43
|
json = body.nil? || body.empty? ? {} : JSON.parse(body)
|
|
34
44
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Scanii
|
|
2
|
+
# A single processing event in a {Scanii::TraceResult}.
|
|
3
|
+
#
|
|
4
|
+
# @see https://scanii.github.io/openapi/v22/
|
|
5
|
+
class TraceEvent
|
|
6
|
+
attr_reader :timestamp, :message
|
|
7
|
+
|
|
8
|
+
def initialize(timestamp:, message:)
|
|
9
|
+
@timestamp = timestamp
|
|
10
|
+
@message = message
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.from_hash(hash)
|
|
14
|
+
new(
|
|
15
|
+
timestamp: hash["timestamp"]&.to_s,
|
|
16
|
+
message: hash["message"]&.to_s
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Scanii
|
|
4
|
+
# Result of Client#retrieve_trace — ordered processing events for a scan.
|
|
5
|
+
#
|
|
6
|
+
# This is a v2.2 preview surface; the API shape may shift before it is
|
|
7
|
+
# marked stable.
|
|
8
|
+
#
|
|
9
|
+
# @see https://scanii.github.io/openapi/v22/
|
|
10
|
+
class TraceResult
|
|
11
|
+
attr_reader :id, :events, :request_id, :host_id, :raw_response
|
|
12
|
+
|
|
13
|
+
def initialize(id:, events:, request_id:, host_id:, raw_response:)
|
|
14
|
+
@id = id
|
|
15
|
+
@events = events
|
|
16
|
+
@request_id = request_id
|
|
17
|
+
@host_id = host_id
|
|
18
|
+
@raw_response = raw_response
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.from_response(body, headers)
|
|
22
|
+
json = body.nil? || body.empty? ? {} : JSON.parse(body)
|
|
23
|
+
|
|
24
|
+
new(
|
|
25
|
+
id: (json["id"] || "").to_s,
|
|
26
|
+
events: Array(json["events"]).map { |e| TraceEvent.from_hash(e) },
|
|
27
|
+
request_id: headers["x-scanii-request-id"],
|
|
28
|
+
host_id: headers["x-scanii-host-id"],
|
|
29
|
+
raw_response: body
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/scanii/version.rb
CHANGED
data/lib/scanii.rb
CHANGED
|
@@ -3,6 +3,8 @@ require_relative "scanii/error"
|
|
|
3
3
|
require_relative "scanii/processing_result"
|
|
4
4
|
require_relative "scanii/pending_result"
|
|
5
5
|
require_relative "scanii/auth_token"
|
|
6
|
+
require_relative "scanii/trace_event"
|
|
7
|
+
require_relative "scanii/trace_result"
|
|
6
8
|
require_relative "scanii/multipart"
|
|
7
9
|
require_relative "scanii/client"
|
|
8
10
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: scanii-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Scanii
|
|
@@ -80,6 +80,8 @@ files:
|
|
|
80
80
|
- lib/scanii/multipart.rb
|
|
81
81
|
- lib/scanii/pending_result.rb
|
|
82
82
|
- lib/scanii/processing_result.rb
|
|
83
|
+
- lib/scanii/trace_event.rb
|
|
84
|
+
- lib/scanii/trace_result.rb
|
|
83
85
|
- lib/scanii/version.rb
|
|
84
86
|
homepage: https://github.com/scanii/scanii-ruby
|
|
85
87
|
licenses:
|