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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

All notable changes to `scanii-ruby` are documented here. Versions follow [SemVer](https://semver.org).

## [1.2.0] — v2.2 surface

### New API

- `Scanii::Client#retrieve_trace(id)` → `Scanii::TraceResult` or `nil` — retrieves the
ordered processing event trace for a scan via `GET /files/{id}/trace`. Returns `nil` on 404
(no trace for that id). v2.2 preview surface; API shape may shift before marked stable.
- `Scanii::Client#process_from_url(location, callback: nil, metadata: nil)` →
`Scanii::ProcessingResult` — submits a URL for synchronous scanning via `POST /files` with
`location` as a multipart/form-data field. Distinct from `fetch`, which submits to
`/files/fetch` for asynchronous server-side fetching. `location` must be a String URL.
v2.2 preview surface.
- `Scanii::TraceResult` — new result class with `id`, `events`, `request_id`, `host_id`,
`raw_response`.
- `Scanii::TraceEvent` — new model with `timestamp` (String) and `message` (String).

### Deprecations

- `Scanii::ProcessingResult#error` — deprecated. The server never populates this field on
successful responses; errors arrive as non-2xx HTTP responses that raise `Scanii::Error`
subclasses. The field still exists and emits a runtime `warn` on access. Will be removed
in a future major version.

## 1.1.0 — Streaming standardization

Adds stream-based `process` and `process_async` methods, aligning scanii-ruby with the
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ puts "findings: #{result.findings.inspect}"
| `process_file(path, metadata:, callback:)` | `POST /files` | `Scanii::ProcessingResult` |
| `process_async(io, filename:, content_type:, metadata:, callback:)` | `POST /files/async` | `Scanii::PendingResult` |
| `process_async_file(path, metadata:, callback:)` | `POST /files/async` | `Scanii::PendingResult` |
| `process_from_url(location, callback:, metadata:)` | `POST /files` | `Scanii::ProcessingResult` (v2.2 preview) |
| `fetch(url, metadata:, callback:)` | `POST /files/fetch` | `Scanii::PendingResult` |
| `retrieve(id)` | `GET /files/{id}` | `Scanii::ProcessingResult` |
| `retrieve_trace(id)` | `GET /files/{id}/trace` | `Scanii::TraceResult` or `nil` (v2.2 preview) |
| `ping` | `GET /ping` | `true` |
| `create_auth_token(timeout_seconds)` | `POST /auth/tokens` | `Scanii::AuthToken` |
| `retrieve_auth_token(id)` | `GET /auth/tokens/{id}` | `Scanii::AuthToken` |
Expand Down
2 changes: 2 additions & 0 deletions lib/scanii.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
require_relative "scanii/processing_result"
require_relative "scanii/pending_result"
require_relative "scanii/auth_token"
require_relative "scanii/trace_event"
require_relative "scanii/trace_result"
require_relative "scanii/multipart"
require_relative "scanii/client"

Expand Down
64 changes: 64 additions & 0 deletions lib/scanii/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,70 @@ def retrieve(id)
ProcessingResult.from_response(resp_body, headers)
end

# Retrieve the processing event trace for a previously submitted scan.
#
# Returns nil when no trace exists for the given id (HTTP 404).
#
# This is a v2.2 preview surface; the API shape may shift before it is
# marked stable.
#
# @param id [String] processing id returned by process or process_file
# @see https://scanii.github.io/openapi/v22/ GET /files/{id}/trace
# @return [Scanii::TraceResult, nil]
def retrieve_trace(id)
raise ArgumentError, "id must not be empty" if id.nil? || id.empty?

status, resp_body, headers = request("GET", "/files/#{url_encode(id)}/trace")
return nil if status == 404

raise_for_status(status, resp_body, headers) unless status == 200
TraceResult.from_response(resp_body, headers)
end

# Submit a remote URL for synchronous scanning.
#
# Sends the URL as a +location+ field in a multipart/form-data POST to
# +/files+. The Scanii server fetches and scans the URL synchronously and
# returns a ProcessingResult. This is distinct from {#fetch}, which submits
# to +/files/fetch+ for asynchronous server-side fetching.
#
# +location+ must be a String URL. This matches the existing {#fetch}
# String-URL convention and the Java reference (processFromUrl(String)).
#
# This is a v2.2 preview surface; the API shape may shift before it is
# marked stable.
#
# @param location [String] URL of the content to scan
# @param callback [String, nil] URL to POST the result to on completion
# @param metadata [Hash{String=>String}, nil] arbitrary key/value pairs attached to the result
# @see https://scanii.github.io/openapi/v22/ POST /files
# @return [Scanii::ProcessingResult]
def process_from_url(location, callback: nil, metadata: nil)
raise ArgumentError, "location must not be empty" if location.nil? || location.to_s.empty?

fields = build_text_fields(metadata, callback)
fields["location"] = location.to_s

boundary = Multipart.make_boundary
body = String.new(encoding: Encoding::BINARY)
fields.each do |name, value|
body << "--#{boundary}\r\n".b
body << "Content-Disposition: form-data; name=\"#{name}\"\r\n".b
body << "Content-Type: text/plain; charset=UTF-8\r\n\r\n".b
body << value.to_s.b
body << "\r\n".b
end
body << "--#{boundary}--\r\n".b

status, resp_body, headers = post(
"/files",
body: body,
content_type: Multipart.make_content_type(boundary)
)
raise_for_status(status, resp_body, headers) unless status == 201
ProcessingResult.from_response(resp_body, headers)
end

# Verify that the configured credentials reach the API.
#
# @see https://scanii.github.io/openapi/v22/ GET /ping
Expand Down
14 changes: 12 additions & 2 deletions lib/scanii/processing_result.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ module Scanii
# @see https://scanii.github.io/openapi/v22/
class ProcessingResult
attr_reader :id, :findings, :checksum, :content_length, :content_type,
:metadata, :creation_date, :error,
:metadata, :creation_date,
:request_id, :host_id, :resource_location, :raw_response

def initialize(id:, findings:, checksum:, content_length:, content_type:,
Expand All @@ -22,13 +22,23 @@ def initialize(id:, findings:, checksum:, content_length:, content_type:,
@content_type = content_type
@metadata = metadata
@creation_date = creation_date
@error = error
@_error = error
@request_id = request_id
@host_id = host_id
@resource_location = resource_location
@raw_response = raw_response
end

# @deprecated The server never populates this field on successful responses;
# errors arrive as non-2xx HTTP responses that raise Scanii::Error
# subclasses. Will be removed in a future major version.
def error
warn "[DEPRECATION] `Scanii::ProcessingResult#error` is deprecated; " \
"rescue Scanii::Error (and its subclasses) to handle server-side errors. " \
"Will be removed in a future major version."
@_error
end

def self.from_response(body, headers)
json = body.nil? || body.empty? ? {} : JSON.parse(body)

Expand Down
20 changes: 20 additions & 0 deletions lib/scanii/trace_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module Scanii
# A single processing event in a {Scanii::TraceResult}.
#
# @see https://scanii.github.io/openapi/v22/
class TraceEvent
attr_reader :timestamp, :message

def initialize(timestamp:, message:)
@timestamp = timestamp
@message = message
end

def self.from_hash(hash)
new(
timestamp: hash["timestamp"]&.to_s,
message: hash["message"]&.to_s
)
end
end
end
33 changes: 33 additions & 0 deletions lib/scanii/trace_result.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
require "json"

module Scanii
# Result of Client#retrieve_trace — ordered processing events for a scan.
#
# This is a v2.2 preview surface; the API shape may shift before it is
# marked stable.
#
# @see https://scanii.github.io/openapi/v22/
class TraceResult
attr_reader :id, :events, :request_id, :host_id, :raw_response

def initialize(id:, events:, request_id:, host_id:, raw_response:)
@id = id
@events = events
@request_id = request_id
@host_id = host_id
@raw_response = raw_response
end

def self.from_response(body, headers)
json = body.nil? || body.empty? ? {} : JSON.parse(body)

new(
id: (json["id"] || "").to_s,
events: Array(json["events"]).map { |e| TraceEvent.from_hash(e) },
request_id: headers["x-scanii-request-id"],
host_id: headers["x-scanii-host-id"],
raw_response: body
)
end
end
end
2 changes: 1 addition & 1 deletion lib/scanii/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module Scanii
VERSION = "1.1.0".freeze
VERSION = "1.2.0".freeze
end
30 changes: 30 additions & 0 deletions test/integration/client_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,36 @@ def test_process_deprecated_path_still_works_and_warns
cleanup(path)
end

# -- retrieve_trace (v2.2 preview) -------------------------------------

def test_retrieve_trace_returns_non_empty_events_for_known_id
path = temp_file(LOCAL_MALWARE_UUID)
result = @client.process_file(path)
trace = @client.retrieve_trace(result.id)
refute_nil trace, "retrieve_trace must return a TraceResult for a known id"
assert_kind_of Scanii::TraceResult, trace
refute_empty trace.events, "events array must be non-empty for a known processing id"
assert(trace.events.all?(Scanii::TraceEvent))
ensure
cleanup(path)
end

def test_retrieve_trace_returns_nil_for_unknown_id
result = @client.retrieve_trace("does-not-exist-trace-#{Process.pid}")
assert_nil result
end

# -- process_from_url (v2.2 preview) -----------------------------------

def test_process_from_url_returns_result_with_eicar_finding
url = "#{self.class.endpoint}/static/eicar.txt"
result = @client.process_from_url(url)
refute_nil result, "process_from_url must return a ProcessingResult"
assert_kind_of Scanii::ProcessingResult, result
assert_includes result.findings, "content.malicious.eicar-test-signature",
"expected EICAR finding; got: #{result.findings.inspect}"
end

# -- fetch --------------------------------------------------------------

def test_fetch_returns_pending_result
Expand Down
Loading