api2convert 10.2.1 → 10.3.1

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.
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api2Convert
4
+ module Model
5
+ # A cloud-storage delivery target for a conversion's output:
6
+ # `{ type:<provider>, parameters, credentials }`.
7
+ #
8
+ # Attach one (or more) to a conversion via
9
+ # `client.convert(..., output_targets: [...])` / `convert_async(...)`, or
10
+ # inline in a raw `jobs.create` conversion map. When any output target is set
11
+ # the conversion delivers straight to your storage and produces **no** local
12
+ # output — so `convert` returns the completed job without downloading.
13
+ #
14
+ # This wave ships the **generic** shape only (`type` + free-form
15
+ # `parameters`/`credentials`); the per-provider output keys live in a separate
16
+ # service and diverge per provider, so there are no per-provider output
17
+ # factories yet.
18
+ #
19
+ # Serialization ({#to_h}) emits `{ type, parameters, credentials }` and
20
+ # **omits `status`** (server-set, read-only). On read ({.from_hash}) `type`,
21
+ # `parameters` and `status` round-trip as raw values; `credentials` are
22
+ # **never** surfaced (the API returns them empty). `credentials` ride in the
23
+ # plaintext body, so {#inspect} masks the whole object to `[REDACTED]`.
24
+ class OutputTarget
25
+ attr_reader :type, :parameters, :credentials, :status
26
+
27
+ # +status+ is server-set on read (`waiting|uploading|completed|failed`) and
28
+ # never sent on create.
29
+ def initialize(type:, parameters: {}, credentials: {}, status: nil)
30
+ @type = type
31
+ @parameters = parameters
32
+ @credentials = credentials
33
+ @status = status
34
+ freeze
35
+ end
36
+
37
+ # Generic constructor accepting a {CloudProvider} constant or a
38
+ # forward-compat string.
39
+ def self.of(type, parameters: {}, credentials: {})
40
+ new(type: type.to_s, parameters: parameters, credentials: credentials)
41
+ end
42
+
43
+ # The wire descriptor sent on create — `{ type, parameters, credentials }`,
44
+ # with `status` omitted (server-set, read-only).
45
+ def to_h
46
+ {
47
+ "type" => @type,
48
+ "parameters" => @parameters,
49
+ "credentials" => @credentials
50
+ }
51
+ end
52
+
53
+ # Hydrate from a `GET /jobs/{id}` `output_target[]` element. `type`/`status`
54
+ # stay raw strings (an unknown provider round-trips untyped); `credentials`
55
+ # are deliberately not surfaced.
56
+ def self.from_hash(data)
57
+ d = Support::Data.as_object(data)
58
+ new(
59
+ type: Support::Data.as_str(d["type"]),
60
+ parameters: Support::Data.as_object(d["parameters"]),
61
+ credentials: {},
62
+ status: Support::Data.nullable_str(d["status"])
63
+ )
64
+ end
65
+
66
+ # Redacted representation — credentials masked. Safe to log.
67
+ def inspect
68
+ "#<#{self.class.name} type=#{@type.inspect} " \
69
+ "parameters=#{Support::Redactor.parameters(@parameters).inspect} " \
70
+ "credentials=#{Support::Redactor::MARKER} status=#{@status.inspect}>"
71
+ end
72
+
73
+ def to_s
74
+ inspect
75
+ end
76
+ end
77
+ end
78
+ end
@@ -52,10 +52,14 @@ module Api2Convert
52
52
  end
53
53
 
54
54
  # Attach an input by descriptor, e.g. a remote URL:
55
- # `add_input(job_id, { "type" => "remote", "source" => "https://..." })`.
55
+ # `add_input(job_id, { "type" => "remote", "source" => "https://..." })`,
56
+ # a Google Drive picker
57
+ # (`{ "type" => "gdrive_picker", "source" => file_id, "credentials" => { "token" => ... } }`),
58
+ # or a {Model::CloudInput} builder.
56
59
  def add_input(job_id, descriptor)
60
+ payload = descriptor.is_a?(Model::CloudInput) ? descriptor.to_h : descriptor
57
61
  Model::InputFile.from_hash(
58
- @transport.request("POST", "/jobs/#{Support::Data.encode_segment(job_id)}/input", descriptor)
62
+ @transport.request("POST", "/jobs/#{Support::Data.encode_segment(job_id)}/input", payload)
59
63
  )
60
64
  end
61
65
 
@@ -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.1"
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"