ve-tos-ruby-sdk 0.1.0 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30e5d9300d235ffbbd406f29793424c09d903c4a720b9015307bf1e56f111665
4
- data.tar.gz: 7f3aaf5b4880ecc5bd372c7de50a53234a2ede71669f28a6fd74871775667aaf
3
+ metadata.gz: 95bc0ad985868c8d32fa03105d98e288a7b0deb98d2ad148a359019a6da12a28
4
+ data.tar.gz: b40de0e450ed402f29090100918f5ebae9494f981f1cd71c2ec1824d47073bce
5
5
  SHA512:
6
- metadata.gz: 2637093b77a69369532f95e01179d3badaccf9ee2199bd65c44f1b3e738185446ce561562a2bfc9302a1d87b8bf8f0b9edeb79267612b81978bcf1430f8938ac
7
- data.tar.gz: e30a3db93b8c50595f2571a919852c5e3ce0b7c8493b79a04f39135511b2a9675a77447ad8fbbad90c0bbca14db8c1409cecf95bf9b745085fab7e1322ba88bf
6
+ metadata.gz: 62f7c51e8891584e346be7bcccb6ffe4a25032f016d7abfb49d19e21d89c4addcb5195f0dc83e3b9d253ca2bc00ab87cbad3e0fbb0eb3a1f7add2ea0c34fc173
7
+ data.tar.gz: f8d743fffb0e46db0588911a6223170dbf9c664abbf4c978bb2cab927ecf4f8778d336594c93c9c0e9d890a01d34f5ce626e2671e853f76ecc39eb9ba425d4a4
data/lib/tos/bucket.rb CHANGED
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "rexml/document"
4
-
5
3
  module TOS
6
4
  # All operations are bucket-scoped: get a bucket via `client.bucket(name)`
7
5
  # then call put_object / get_object / etc. on it.
@@ -66,7 +64,10 @@ module TOS
66
64
  keys = Array(keys).reject { |k| k.to_s.empty? }
67
65
  return [] if keys.empty?
68
66
 
69
- body = build_delete_payload(keys, quiet)
67
+ body = JSON.generate(
68
+ Quiet: quiet,
69
+ Objects: keys.map { |k| { Key: k.to_s } },
70
+ )
70
71
  content_md5 = OpenSSL::Digest::MD5.base64digest(body)
71
72
 
72
73
  client.request(
@@ -74,7 +75,7 @@ module TOS
74
75
  host: host,
75
76
  path: "/",
76
77
  query: { "delete" => "" },
77
- headers: { "Content-Type" => "application/xml", "Content-MD5" => content_md5 },
78
+ headers: { "Content-Type" => "application/json", "Content-MD5" => content_md5 },
78
79
  body: body,
79
80
  )
80
81
  end
@@ -96,32 +97,15 @@ module TOS
96
97
  end
97
98
 
98
99
  def parse_list_response(res)
99
- doc = REXML::Document.new(res.body)
100
- root = doc.root
101
- keys = root.get_elements("Contents/Key").map(&:text)
102
- truncated = root.elements["IsTruncated"]&.text == "true"
103
- next_token = root.elements["NextContinuationToken"]&.text
100
+ data = JSON.parse(res.body)
101
+ keys = (data["Contents"] || []).map { |obj| obj["Key"] }
104
102
 
105
103
  {
106
104
  keys: keys,
107
- is_truncated: truncated,
108
- next_continuation_token: next_token,
105
+ is_truncated: data["IsTruncated"] == true,
106
+ next_continuation_token: data["NextContinuationToken"],
109
107
  raw: res,
110
108
  }
111
109
  end
112
-
113
- def build_delete_payload(keys, quiet)
114
- doc = REXML::Document.new
115
- doc.add_element("Delete").tap do |delete|
116
- delete.add_element("Quiet").text = quiet ? "true" : "false"
117
- keys.each do |key|
118
- obj = delete.add_element("Object")
119
- obj.add_element("Key").text = key
120
- end
121
- end
122
- out = +""
123
- doc.write(out)
124
- out
125
- end
126
110
  end
127
111
  end
data/lib/tos/client.rb CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "net/http"
4
4
  require "uri"
5
- require "rexml/document"
6
5
 
7
6
  module TOS
8
7
  # Low-level HTTP client. Holds credentials, region, endpoint resolution, and
@@ -53,7 +52,7 @@ module TOS
53
52
 
54
53
  signed = signer.sign_headers(
55
54
  method: method, path: path, query: query,
56
- headers: headers, body: body
55
+ headers: headers.merge("Host" => host), body: body
57
56
  )
58
57
  signed.each { |k, v| req[k] = v }
59
58
  # Signer-only headers that aren't in the user's `headers` hash.
@@ -136,12 +135,9 @@ module TOS
136
135
  request_id = res["x-tos-request-id"]
137
136
  return [body, nil, request_id] if body.empty?
138
137
 
139
- doc = REXML::Document.new(body)
140
- code = doc.root&.elements&.[]("Code")&.text
141
- message = doc.root&.elements&.[]("Message")&.text || body
142
- request_id ||= doc.root&.elements&.[]("RequestId")&.text
143
- [message, code, request_id]
144
- rescue REXML::ParseException
138
+ data = JSON.parse(body)
139
+ [data["Message"] || body, data["Code"], request_id || data["RequestId"]]
140
+ rescue JSON::ParserError
145
141
  [body, nil, request_id]
146
142
  end
147
143
 
data/lib/tos/signer.rb CHANGED
@@ -1,17 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "openssl"
4
- require "time"
5
- require "uri"
6
- require "cgi"
4
+ require "erb"
7
5
 
8
6
  module TOS
9
- # Signs HTTP requests for the TOS object service using the
10
- # TOS4-HMAC-SHA256 scheme (analogous to AWS SigV4, but with the "tos"
11
- # service name and a slightly different signed-header policy: by default
12
- # only `content-type` and any `x-tos-*` headers are signed).
13
- #
14
- # Reference: https://github.com/volcengine/ve-tos-golang-sdk/blob/master/tos/sign_v4.go
7
+ # Signs HTTP requests for the TOS object service using TOS4-HMAC-SHA256
8
+ # (AWS SigV4 with the "tos" service name)
15
9
  class Signer
16
10
  ALGORITHM = "TOS4-HMAC-SHA256"
17
11
  SERVICE = "tos"
@@ -32,153 +26,98 @@ module TOS
32
26
  # Sign a request and return a Hash of headers to add (Authorization,
33
27
  # X-Tos-Date, X-Tos-Content-Sha256, plus X-Tos-Security-Token when STS
34
28
  # credentials are used).
35
- #
36
- # @param method [String] HTTP method, uppercased (e.g. "PUT")
37
- # @param path [String] URI path starting with "/" (e.g. "/bucket/key")
38
- # @param query [Hash{String=>String,Array<String>}] query params
39
- # @param headers [Hash] existing request headers (case-insensitive keys)
40
- # @param body [String, nil] request body — may be nil for GET/HEAD/DELETE
41
- # @param now [Time] override for testing
42
29
  def sign_headers(method:, path:, query: {}, headers: {}, body: nil, now: Time.now.utc)
43
30
  datetime = now.strftime("%Y%m%dT%H%M%SZ")
44
- date = datetime[0, 8]
45
31
  content_sha = body ? sha256_hex(body) : EMPTY_SHA256
46
32
 
47
- working_headers = headers.dup
48
- working_headers["X-Tos-Date"] = datetime
49
- working_headers["X-Tos-Content-Sha256"] = content_sha
50
- working_headers["X-Tos-Security-Token"] = @credentials.security_token if @credentials.security_token
33
+ out = headers.dup
34
+ out["X-Tos-Date"] = datetime
35
+ out["X-Tos-Content-Sha256"] = content_sha
36
+ out["X-Tos-Security-Token"] = @credentials.security_token if @credentials.security_token
51
37
 
52
- signed_headers = filter_signed_headers(working_headers)
53
-
54
- canonical_request = build_canonical_request(
55
- method: method.to_s.upcase,
56
- path: path,
57
- query: query,
58
- signed_headers: signed_headers,
59
- content_sha: content_sha
60
- )
61
-
62
- credential_scope = "#{date}/#{@region}/#{SERVICE}/#{REQUEST}"
63
- string_to_sign = [ALGORITHM, datetime, credential_scope, sha256_hex(canonical_request)].join("\n")
64
-
65
- signing_key = derive_signing_key(date)
66
- signature = hmac_hex(signing_key, string_to_sign)
67
-
68
- authorization = build_authorization_header(signed_headers, credential_scope, signature)
69
- working_headers["Authorization"] = authorization
70
-
71
- working_headers
38
+ signed = signed_headers(out)
39
+ out["Authorization"] = authorization(method, path, query, signed, content_sha, datetime)
40
+ out
72
41
  end
73
42
 
74
43
  # Build a presigned URL with all signing material in the query string.
75
- # Returns a fully-qualified URL.
76
44
  def presign(method:, host:, path:, query: {}, expires_in: 3600, now: Time.now.utc)
77
45
  datetime = now.strftime("%Y%m%dT%H%M%SZ")
78
- date = datetime[0, 8]
79
- credential_scope = "#{date}/#{@region}/#{SERVICE}/#{REQUEST}"
80
-
81
- query_with_sig = query.dup
82
- query_with_sig["X-Tos-Algorithm"] = ALGORITHM
83
- query_with_sig["X-Tos-Credential"] = "#{@credentials.access_key_id}/#{credential_scope}"
84
- query_with_sig["X-Tos-Date"] = datetime
85
- query_with_sig["X-Tos-Expires"] = expires_in.to_s
86
- query_with_sig["X-Tos-SignedHeaders"] = ""
87
- query_with_sig["X-Tos-Security-Token"] = @credentials.security_token if @credentials.security_token
88
-
89
- canonical_request = build_canonical_request(
90
- method: method.to_s.upcase,
91
- path: path,
92
- query: query_with_sig,
93
- signed_headers: [],
94
- content_sha: UNSIGNED_PAYLOAD
46
+ params = query.merge(
47
+ "X-Tos-Algorithm" => ALGORITHM,
48
+ "X-Tos-Credential" => "#{@credentials.access_key_id}/#{credential_scope(datetime[0, 8])}",
49
+ "X-Tos-Date" => datetime,
50
+ "X-Tos-Expires" => expires_in.to_s,
51
+ "X-Tos-SignedHeaders" => "",
95
52
  )
53
+ params["X-Tos-Security-Token"] = @credentials.security_token if @credentials.security_token
96
54
 
97
- string_to_sign = [ALGORITHM, datetime, credential_scope, sha256_hex(canonical_request)].join("\n")
98
- signing_key = derive_signing_key(date)
99
- query_with_sig["X-Tos-Signature"] = hmac_hex(signing_key, string_to_sign)
100
-
101
- "https://#{host}#{path}?#{encode_query(query_with_sig)}"
55
+ params["X-Tos-Signature"] = sign(method, path, params, [], UNSIGNED_PAYLOAD, datetime)
56
+ "https://#{host}#{path}?#{encode_query(params)}"
102
57
  end
103
58
 
104
59
  private
105
60
 
106
- # Default policy mirrors the Go SDK: only `content-type` (lower-case) and
107
- # any `x-tos-*` header is signed. `host` is intentionally NOT signed.
108
- def filter_signed_headers(headers)
109
- headers.each_with_object([]) do |(name, value), acc|
110
- key = name.to_s.downcase
111
- next unless key == "content-type" || key.start_with?("x-tos-")
61
+ # Default policy: sign `host`, `content-type`, and any `x-tos-*` header.
62
+ def signed_headers(headers)
63
+ headers
64
+ .map { |k, v| [k.to_s.downcase, normalize_value(v)] }
65
+ .select { |k, _| k == "host" || k == "content-type" || k.start_with?("x-tos-") }
66
+ .sort
67
+ end
112
68
 
113
- acc << [key, normalize_value(value)]
114
- end.sort_by(&:first)
69
+ def authorization(method, path, query, signed, content_sha, datetime)
70
+ header_list = signed.map(&:first).join(";")
71
+ signature = sign(method, path, query, signed, content_sha, datetime)
72
+ "#{ALGORITHM} Credential=#{@credentials.access_key_id}/#{credential_scope(datetime[0, 8])}," \
73
+ "SignedHeaders=#{header_list},Signature=#{signature}"
115
74
  end
116
75
 
117
- def build_canonical_request(method:, path:, query:, signed_headers:, content_sha:)
118
- canonical_headers = signed_headers.map { |k, v| "#{k}:#{v}\n" }.join
119
- header_list = signed_headers.map(&:first).join(";")
76
+ def sign(method, path, query, signed, content_sha, datetime)
77
+ canonical = canonical_request(method, path, query, signed, content_sha)
78
+ string_to_sign = [ALGORITHM, datetime, credential_scope(datetime[0, 8]), sha256_hex(canonical)].join("\n")
79
+ hmac_hex(signing_key(datetime[0, 8]), string_to_sign)
80
+ end
120
81
 
82
+ def canonical_request(method, path, query, signed, content_sha)
121
83
  [
122
- method,
84
+ method.to_s.upcase,
123
85
  encode_path(path),
124
86
  encode_query(query),
125
- canonical_headers,
126
- header_list,
127
- content_sha
87
+ signed.map { |k, v| "#{k}:#{v}\n" }.join,
88
+ signed.map(&:first).join(";"),
89
+ content_sha,
128
90
  ].join("\n")
129
91
  end
130
92
 
131
- def build_authorization_header(signed_headers, credential_scope, signature)
132
- header_list = signed_headers.map(&:first).join(";")
133
- "#{ALGORITHM} Credential=#{@credentials.access_key_id}/#{credential_scope}," \
134
- "SignedHeaders=#{header_list},Signature=#{signature}"
93
+ def credential_scope(date)
94
+ "#{date}/#{@region}/#{SERVICE}/#{REQUEST}"
135
95
  end
136
96
 
137
- def derive_signing_key(date)
138
- k_date = hmac(@credentials.secret_access_key, date)
139
- k_region = hmac(k_date, @region)
140
- k_service = hmac(k_region, SERVICE)
141
- hmac(k_service, REQUEST)
97
+ def signing_key(date)
98
+ key = hmac(@credentials.secret_access_key, date)
99
+ key = hmac(key, @region)
100
+ key = hmac(key, SERVICE)
101
+ hmac(key, REQUEST)
142
102
  end
143
103
 
144
104
  def normalize_value(value)
145
105
  Array(value).map { |v| v.to_s.split(/\s+/).join(" ") }.join(",")
146
106
  end
147
107
 
148
- # RFC 3986 percent-encoding for the URI path. Slashes are preserved.
149
108
  def encode_path(path)
150
- path.split("/", -1).map { |segment| uri_escape(segment) }.join("/")
109
+ path.split("/", -1).map { |seg| ERB::Util.url_encode(seg) }.join("/")
151
110
  end
152
111
 
153
- # Sort by key, then by value, encode and join with `&`.
154
112
  def encode_query(query)
155
- pairs = []
156
- query.each do |key, values|
157
- Array(values).each do |value|
158
- pairs << [uri_escape(key.to_s), uri_escape(value.to_s)]
159
- end
160
- end
161
- pairs.sort.map { |k, v| "#{k}=#{v}" }.join("&")
162
- end
163
-
164
- def uri_escape(str)
165
- # URI.encode_www_form_component encodes space as "+", which is wrong for
166
- # SigV4. Use a manual escape that matches Go's url.QueryEscape rules
167
- # (which percent-encodes everything except unreserved chars), but preserve
168
- # nothing else.
169
- str.b.gsub(/([^A-Za-z0-9\-_.~])/) { format("%%%02X", $1.ord) }
170
- end
171
-
172
- def sha256_hex(data)
173
- OpenSSL::Digest::SHA256.hexdigest(data.to_s)
174
- end
175
-
176
- def hmac(key, data)
177
- OpenSSL::HMAC.digest("SHA256", key, data)
113
+ query.flat_map { |k, vs| Array(vs).map { |v| [ERB::Util.url_encode(k.to_s), ERB::Util.url_encode(v.to_s)] } }
114
+ .sort
115
+ .map { |k, v| "#{k}=#{v}" }
116
+ .join("&")
178
117
  end
179
118
 
180
- def hmac_hex(key, data)
181
- OpenSSL::HMAC.hexdigest("SHA256", key, data)
182
- end
119
+ def sha256_hex(data) = OpenSSL::Digest::SHA256.hexdigest(data.to_s)
120
+ def hmac(key, data) = OpenSSL::HMAC.digest("SHA256", key, data)
121
+ def hmac_hex(key, data) = OpenSSL::HMAC.hexdigest("SHA256", key, data)
183
122
  end
184
123
  end
data/lib/tos/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TOS
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
data/lib/tos.rb CHANGED
@@ -10,7 +10,7 @@ require_relative "tos/response"
10
10
  require_relative "tos/client"
11
11
  require_relative "tos/bucket"
12
12
 
13
- # A minimal Ruby SDK for Volcengine TOS (Torch Object Storage).
13
+ # Ruby SDK for Volcengine TOS (Torch Object Storage).
14
14
  #
15
15
  # client = TOS::Client.new(
16
16
  # access_key_id: ENV["TOS_ACCESS_KEY_ID"],
@@ -16,9 +16,6 @@ Gem::Specification.new do |spec|
16
16
  spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE", "ve-tos-ruby-sdk.gemspec"]
17
17
  spec.require_paths = ["lib"]
18
18
 
19
- # rexml is no longer a default gem under Bundler-managed contexts.
20
- spec.add_dependency "rexml", ">= 3.2"
21
-
22
19
  spec.add_development_dependency "rspec", "~> 3.12"
23
20
  spec.add_development_dependency "webmock", "~> 3.18"
24
21
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ve-tos-ruby-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Renny
@@ -9,20 +9,6 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: rexml
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '3.2'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: '3.2'
26
12
  - !ruby/object:Gem::Dependency
27
13
  name: rspec
28
14
  requirement: !ruby/object:Gem::Requirement