api2convert 10.2.1 → 10.3.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/AGENTS.md +1 -1
- data/README.md +5 -0
- data/docs/SDK_CONTRACT.md +57 -6
- data/lib/api2convert/client.rb +29 -5
- data/lib/api2convert/cloud_provider.rb +29 -0
- data/lib/api2convert/http/net_http_sender.rb +31 -3
- data/lib/api2convert/http/request.rb +7 -5
- data/lib/api2convert/http/transport.rb +18 -3
- data/lib/api2convert/model/cloud_input.rb +108 -0
- data/lib/api2convert/model/conversion.rb +7 -3
- data/lib/api2convert/model/input_file.rb +8 -3
- data/lib/api2convert/model/output_target.rb +78 -0
- data/lib/api2convert/resource/jobs.rb +6 -2
- data/lib/api2convert/resource/stats.rb +2 -1
- data/lib/api2convert/result.rb +51 -20
- data/lib/api2convert/support/data.rb +8 -1
- data/lib/api2convert/support/redactor.rb +83 -0
- data/lib/api2convert/upload/file_uploader.rb +2 -2
- data/lib/api2convert/version.rb +1 -1
- data/lib/api2convert.rb +4 -0
- data/openapi/api2convert.openapi.json +51 -51
- metadata +6 -2
|
@@ -4,7 +4,8 @@ module Api2Convert
|
|
|
4
4
|
module Resource
|
|
5
5
|
# API usage statistics. The response shape is free-form (returned as-is).
|
|
6
6
|
#
|
|
7
|
-
# +filter+ is
|
|
7
|
+
# +filter+ is "single" (only the calling API key) or "all" (every key on the account, the default).
|
|
8
|
+
# The request is scoped by the X-Api2convert-Api-Key header, so never pass a key as +filter+.
|
|
8
9
|
class Stats
|
|
9
10
|
def initialize(transport)
|
|
10
11
|
@transport = transport
|
data/lib/api2convert/result.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
4
5
|
|
|
5
6
|
module Api2Convert
|
|
6
7
|
module Result
|
|
@@ -24,11 +25,11 @@ module Api2Convert
|
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
# Stream the file to disk. +path_or_dir+ is a file path, or a directory (the
|
|
27
|
-
# API filename is used). The body is streamed
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# path written
|
|
28
|
+
# API filename is used). The body is streamed chunk-by-chunk to a sibling temp
|
|
29
|
+
# file (never buffered whole in memory), then atomically renamed over the
|
|
30
|
+
# target only after a clean write+close — so an arbitrarily large output cannot
|
|
31
|
+
# exhaust memory and a mid-stream failure never truncates the target nor
|
|
32
|
+
# destroys a pre-existing complete file at that path. Returns the path written.
|
|
32
33
|
def save(path_or_dir, download_password = nil)
|
|
33
34
|
target = resolve_target(path_or_dir.to_s)
|
|
34
35
|
parent = File.dirname(target)
|
|
@@ -48,7 +49,7 @@ module Api2Convert
|
|
|
48
49
|
def contents(download_password = nil)
|
|
49
50
|
password = resolve_password(download_password)
|
|
50
51
|
# A passwordless download follows storage redirects; a password-protected
|
|
51
|
-
# one must not (the X-
|
|
52
|
+
# one must not (the X-Api2convert-Download-Password header could leak on a redirect).
|
|
52
53
|
@transport.download(@output.uri, headers(password), follow_redirects: password.nil?)
|
|
53
54
|
end
|
|
54
55
|
|
|
@@ -65,30 +66,60 @@ module Api2Convert
|
|
|
65
66
|
private
|
|
66
67
|
|
|
67
68
|
def stream_to_file(target, password)
|
|
68
|
-
|
|
69
|
+
parent = File.dirname(target)
|
|
70
|
+
parent = "." if parent.empty?
|
|
71
|
+
# Stream to a sibling temp file and rename over the target only after a clean
|
|
72
|
+
# write+close. This never truncates the target up front and never destroys a
|
|
73
|
+
# pre-existing complete file on a mid-stream failure — a download either fully
|
|
74
|
+
# replaces the target or leaves it untouched.
|
|
75
|
+
temp = create_temp(parent, target)
|
|
76
|
+
|
|
77
|
+
committed = false
|
|
69
78
|
begin
|
|
70
|
-
|
|
71
|
-
@
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
@transport.download(
|
|
80
|
+
@output.uri, headers(password), follow_redirects: password.nil?, sink: temp
|
|
81
|
+
)
|
|
82
|
+
# Flush + close BEFORE the rename so a truncated-on-close write can never be
|
|
83
|
+
# committed as a complete file, and a close/flush fault surfaces here rather
|
|
84
|
+
# than being swallowed on the success path.
|
|
85
|
+
temp.close
|
|
86
|
+
File.rename(temp.path, target)
|
|
87
|
+
committed = true
|
|
88
|
+
rescue SystemCallError => e
|
|
89
|
+
# A network read failure mid-stream is already a (non-retryable) NetworkError
|
|
90
|
+
# raised by the sender, which is NOT a SystemCallError and so passes straight
|
|
91
|
+
# through; reaching this rescue means a write / flush / rename fault — a
|
|
92
|
+
# genuine filesystem error.
|
|
93
|
+
raise Api2Convert::Error, "Could not write file: #{target}: #{e.message}"
|
|
78
94
|
ensure
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
|
|
95
|
+
# On any failure (network break, refused-redirect NetworkError, write/close/
|
|
96
|
+
# rename fault) only the temp file is removed; the target is never touched
|
|
97
|
+
# unless the rename already committed a complete download.
|
|
98
|
+
unless committed
|
|
99
|
+
begin
|
|
100
|
+
temp.close unless temp.closed?
|
|
101
|
+
rescue SystemCallError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
FileUtils.rm_f(temp.path)
|
|
105
|
+
end
|
|
83
106
|
end
|
|
84
107
|
end
|
|
85
108
|
|
|
109
|
+
def create_temp(parent, target)
|
|
110
|
+
temp = Tempfile.create([".a2c-download-", ".part"], parent)
|
|
111
|
+
temp.binmode
|
|
112
|
+
temp
|
|
113
|
+
rescue SystemCallError
|
|
114
|
+
raise Api2Convert::Error, "Could not open file for writing: #{target}"
|
|
115
|
+
end
|
|
116
|
+
|
|
86
117
|
def resolve_password(download_password)
|
|
87
118
|
download_password.nil? ? @download_password : download_password
|
|
88
119
|
end
|
|
89
120
|
|
|
90
121
|
def headers(password)
|
|
91
|
-
password.nil? ? {} : { "X-
|
|
122
|
+
password.nil? ? {} : { "X-Api2convert-Download-Password" => password }
|
|
92
123
|
end
|
|
93
124
|
|
|
94
125
|
def resolve_target(path_or_dir)
|
|
@@ -36,10 +36,17 @@ module Api2Convert
|
|
|
36
36
|
end
|
|
37
37
|
if value.is_a?(String)
|
|
38
38
|
begin
|
|
39
|
-
|
|
39
|
+
parsed = Float(value)
|
|
40
40
|
rescue ArgumentError, TypeError
|
|
41
41
|
return nil
|
|
42
42
|
end
|
|
43
|
+
# A numeric string that overflows Float to +/-Infinity (e.g. "1e400")
|
|
44
|
+
# must fall back to nil, not raise: Float::INFINITY.to_i / NAN.to_i throw
|
|
45
|
+
# FloatDomainError, and this helper must NEVER raise on a hostile payload
|
|
46
|
+
# (mirrors the Float branch's finite? guard above).
|
|
47
|
+
return nil unless parsed.finite?
|
|
48
|
+
|
|
49
|
+
return parsed.to_i
|
|
43
50
|
end
|
|
44
51
|
nil
|
|
45
52
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Api2Convert
|
|
4
|
+
module Support
|
|
5
|
+
# Credential redaction for cloud connectors.
|
|
6
|
+
#
|
|
7
|
+
# Cloud `credentials` ride in the plaintext request body, so they must never
|
|
8
|
+
# surface where a value object or an SDK-emitted string could leak them. This
|
|
9
|
+
# helper centralizes the masks the contract mandates:
|
|
10
|
+
#
|
|
11
|
+
# - the **whole `credentials` object** collapses to {MARKER} on every
|
|
12
|
+
# object-inspection path (`#inspect` / `#to_s`);
|
|
13
|
+
# - any `parameters` leaf whose key contains a sensitive token
|
|
14
|
+
# ({SENSITIVE_SUBSTRINGS}, case-insensitive substring) collapses to {MARKER};
|
|
15
|
+
# - the decoded error body is deep-walked ({redact_body}) as belt-and-
|
|
16
|
+
# suspenders — the API only ever echoes field *names*, never a credential
|
|
17
|
+
# *value*, but a future server/proxy change must not be able to leak one.
|
|
18
|
+
#
|
|
19
|
+
# Internal helper, not part of the public API.
|
|
20
|
+
module Redactor
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# The fixed, fleet-wide redaction marker (D9).
|
|
24
|
+
MARKER = "[REDACTED]"
|
|
25
|
+
|
|
26
|
+
# Case-insensitive substrings that mark a key as carrying a secret. A key
|
|
27
|
+
# containing any of these has its whole value masked.
|
|
28
|
+
SENSITIVE_SUBSTRINGS = %w[
|
|
29
|
+
token password passwd secret key keyfile
|
|
30
|
+
credential passphrase sas sig signature
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# Precompiled union: a key is sensitive iff it *contains* any substring
|
|
34
|
+
# above. Equivalent to `SENSITIVE_SUBSTRINGS.any? { |s| key.include?(s) }`
|
|
35
|
+
# but written as a regex so Style/ArrayIntersect can't rewrite the
|
|
36
|
+
# String#include? substring test into Array#intersect? (which would need an
|
|
37
|
+
# array argument and change the semantics).
|
|
38
|
+
SENSITIVE_PATTERN = Regexp.union(SENSITIVE_SUBSTRINGS).freeze
|
|
39
|
+
|
|
40
|
+
# Whether a key name marks its value as sensitive (case-insensitive
|
|
41
|
+
# substring match). Accepts String or Symbol keys.
|
|
42
|
+
def sensitive_key?(key)
|
|
43
|
+
SENSITIVE_PATTERN.match?(key.to_s.downcase)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Mask sensitive leaves of a `parameters` map: any key matching
|
|
47
|
+
# {sensitive_key?} has its value replaced by {MARKER}; nested Hashes are
|
|
48
|
+
# walked recursively. Non-secret keys (`bucket`, `host`, `file`,
|
|
49
|
+
# `container`, `projectid`, …) are left untouched.
|
|
50
|
+
def parameters(params)
|
|
51
|
+
return params unless params.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
params.each_with_object({}) do |(key, value), out|
|
|
54
|
+
out[key] =
|
|
55
|
+
if sensitive_key?(key)
|
|
56
|
+
MARKER
|
|
57
|
+
elsif value.is_a?(Hash)
|
|
58
|
+
parameters(value)
|
|
59
|
+
else
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Deep-walk a decoded error body and mask the value of every sensitive key
|
|
66
|
+
# (including a flattened/dotted key like
|
|
67
|
+
# `input.0.credentials.secretaccesskey`) to {MARKER}. Nested Hashes and
|
|
68
|
+
# Arrays are walked; scalars pass through.
|
|
69
|
+
def redact_body(body)
|
|
70
|
+
case body
|
|
71
|
+
when Hash
|
|
72
|
+
body.each_with_object({}) do |(key, value), out|
|
|
73
|
+
out[key] = sensitive_key?(key) ? MARKER : redact_body(value)
|
|
74
|
+
end
|
|
75
|
+
when Array
|
|
76
|
+
body.map { |value| redact_body(value) }
|
|
77
|
+
else
|
|
78
|
+
body
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -10,7 +10,7 @@ module Api2Convert
|
|
|
10
10
|
# This step is intentionally hand-written — it is NOT described by the OpenAPI
|
|
11
11
|
# spec. It posts a `multipart/form-data` body (field `file`) to
|
|
12
12
|
# `{job.server}/upload-file/{job.id}` and authenticates with the per-job
|
|
13
|
-
# `X-
|
|
13
|
+
# `X-Api2convert-Token` header — never the account API key. The body is streamed, so
|
|
14
14
|
# large files are not read into memory. Internal.
|
|
15
15
|
class FileUploader
|
|
16
16
|
def initialize(transport)
|
|
@@ -39,7 +39,7 @@ module Api2Convert
|
|
|
39
39
|
@transport.build_request(
|
|
40
40
|
"POST", url,
|
|
41
41
|
headers: {
|
|
42
|
-
"X-
|
|
42
|
+
"X-Api2convert-Token" => token,
|
|
43
43
|
"Content-Type" => "multipart/form-data; boundary=#{boundary}"
|
|
44
44
|
},
|
|
45
45
|
body_stream: body_stream,
|
data/lib/api2convert/version.rb
CHANGED
data/lib/api2convert.rb
CHANGED
|
@@ -21,10 +21,14 @@ require_relative "api2convert/errors"
|
|
|
21
21
|
require_relative "api2convert/config"
|
|
22
22
|
require_relative "api2convert/support/data"
|
|
23
23
|
require_relative "api2convert/support/secret"
|
|
24
|
+
require_relative "api2convert/support/redactor"
|
|
24
25
|
require_relative "api2convert/job_status"
|
|
25
26
|
require_relative "api2convert/input_type"
|
|
27
|
+
require_relative "api2convert/cloud_provider"
|
|
26
28
|
|
|
27
29
|
require_relative "api2convert/model/status"
|
|
30
|
+
require_relative "api2convert/model/output_target"
|
|
31
|
+
require_relative "api2convert/model/cloud_input"
|
|
28
32
|
require_relative "api2convert/model/conversion"
|
|
29
33
|
require_relative "api2convert/model/input_file"
|
|
30
34
|
require_relative "api2convert/model/output_file"
|