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.
@@ -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 either an API key to scope to, or `"all"`.
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
@@ -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 straight to the file
28
- # chunk-by-chunk (never buffered whole in memory), so an arbitrarily large
29
- # output cannot exhaust memory. A mid-stream failure removes the partial file
30
- # so a truncated download never masquerades as a complete result. Returns the
31
- # path written to.
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-Oc-Download-Password header could leak on a redirect).
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
- success = false
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
- File.open(target, "wb") do |file|
71
- @transport.download(
72
- @output.uri, headers(password), follow_redirects: password.nil?, sink: file
73
- )
74
- success = true
75
- end
76
- rescue SystemCallError
77
- raise Api2Convert::Error, "Could not open file for writing: #{target}"
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
- # Any failure (open error, network break, a refused-redirect NetworkError,
80
- # a mid-stream break) leaves a partial or empty file remove it so a
81
- # truncated download can never masquerade as a complete result.
82
- FileUtils.rm_f(target) unless success
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-Oc-Download-Password" => password }
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
- return Float(value).to_i
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-Oc-Token` header — never the account API key. The body is streamed, so
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-Oc-Token" => token,
42
+ "X-Api2convert-Token" => token,
43
43
  "Content-Type" => "multipart/form-data; boundary=#{boundary}"
44
44
  },
45
45
  body_stream: body_stream,
@@ -4,5 +4,5 @@ module Api2Convert
4
4
  # The SDK version. Kept in lock-step with the sibling SDKs (PHP, Python, Java,
5
5
  # Node.js): all official ports implement the same `docs/SDK_CONTRACT.md` and
6
6
  # version together.
7
- VERSION = "10.2.1"
7
+ VERSION = "10.3.0"
8
8
  end
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"