ruby_everywhere 0.1.7 → 0.1.9

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.
@@ -10,10 +10,22 @@
10
10
  // Everywhere.platform // "desktop" | "mobile" | "browser"
11
11
  // Everywhere.os // "macos" | "windows" | "linux" | "ios" | "android" | "chromeos" | "unknown"
12
12
  // Everywhere.native // true inside a RubyEverywhere app
13
+ // Everywhere.version // app version (everywhere.yml app.version); null in a plain browser tab
13
14
  // Everywhere.notify({ title, body }) // native / web Notification / console
14
15
  // Everywhere.confirm("Sure?") // Promise<boolean>, native dialog when possible
15
16
  // Everywhere.on("menu", handler) // shell events; returns unsubscribe fn
16
17
  // Everywhere.visit("/settings") // Turbo.visit with location fallback
18
+ //
19
+ // Everywhere.updates.supported // true when the shell has an update feed
20
+ // Everywhere.updates.channel // effective channel ("stable", "beta", …)
21
+ // Everywhere.updates.check() // Promise<{available, version?, notes?, notesHtml?}>
22
+ // Everywhere.updates.install() // download/verify/swap/relaunch
23
+ // Everywhere.updates.setChannel("beta")// Promise<{channel}>, persisted by the shell
24
+ // Everywhere.updates.on("available" | "none" | "progress" | "ready" | "error" | "channel", handler)
25
+ //
26
+ // notes is the markdown source; notesHtml is the same notes pre-rendered to
27
+ // HTML (from the signed update feed — your own content), ready for a
28
+ // changelog modal: el.innerHTML = notesHtml.
17
29
 
18
30
  const config =
19
31
  (typeof window !== "undefined" && window.__EVERYWHERE_CONFIG__) || {}
@@ -74,6 +86,51 @@ const desktop = {
74
86
 
75
87
  clipboardRead() {
76
88
  return window.__TAURI__.clipboardManager.readText()
89
+ },
90
+
91
+ // Ask the shell to check its update feed; resolve off whichever update
92
+ // event answers first. The shell always replies to an explicit check
93
+ // (available / none / error), the timeout is just a safety net.
94
+ updatesCheck() {
95
+ return new Promise((resolve, reject) => {
96
+ const offs = []
97
+ let timer = null
98
+ const settle = (fn) => (payload) => {
99
+ offs.forEach((off) => off())
100
+ clearTimeout(timer)
101
+ fn(payload)
102
+ }
103
+ offs.push(this.on("update-available", settle((p) =>
104
+ resolve({ available: true, version: p.version,
105
+ notes: p.notes ?? null, notesHtml: p.notes_html ?? null }))))
106
+ offs.push(this.on("update-none", settle(() => resolve({ available: false }))))
107
+ offs.push(this.on("update-error", settle((p) => reject(new Error(p.message)))))
108
+ timer = setTimeout(settle(() => resolve({ available: false, timedOut: true })), 30000)
109
+ window.__TAURI__.event.emit("everywhere:update-check", {})
110
+ })
111
+ },
112
+
113
+ updatesInstall() {
114
+ return window.__TAURI__.event.emit("everywhere:update-install", {})
115
+ },
116
+
117
+ // Ask the shell to switch update channels. The shell validates, persists the
118
+ // choice (it survives relaunches and overrides everywhere.yml), and always
119
+ // answers: update-channel on success, update-error on rejection.
120
+ updatesSetChannel(channel) {
121
+ return new Promise((resolve, reject) => {
122
+ const offs = []
123
+ let timer = null
124
+ const settle = (fn) => (payload) => {
125
+ offs.forEach((off) => off())
126
+ clearTimeout(timer)
127
+ fn(payload)
128
+ }
129
+ offs.push(this.on("update-channel", settle((p) => resolve({ channel: p.channel }))))
130
+ offs.push(this.on("update-error", settle((p) => reject(new Error(p.message)))))
131
+ timer = setTimeout(settle(() => reject(new Error("channel change timed out"))), 10000)
132
+ window.__TAURI__.event.emit("everywhere:update-set-channel", { channel })
133
+ })
77
134
  }
78
135
  }
79
136
 
@@ -114,6 +171,21 @@ const browser = {
114
171
  try { return await navigator.clipboard.readText() } catch (_) { return null }
115
172
  }
116
173
  return null
174
+ },
175
+
176
+ // Auto-updates only exist inside the shell; the browser tab is always "current".
177
+ updatesCheck() {
178
+ return Promise.resolve({ available: false, unsupported: true })
179
+ },
180
+
181
+ updatesInstall() {
182
+ console.log("[everywhere] updates unavailable in the browser")
183
+ return Promise.resolve()
184
+ },
185
+
186
+ updatesSetChannel() {
187
+ console.log("[everywhere] update channel unavailable in the browser")
188
+ return Promise.resolve({ channel: null, unsupported: true })
117
189
  }
118
190
  }
119
191
 
@@ -136,10 +208,18 @@ const platform = detectPlatform()
136
208
  const os = detectOS()
137
209
  const adapter = adapters[platform]
138
210
 
211
+ // Mutable so setChannel can keep updates.channel truthful without a reload.
212
+ let updatesChannel = (config.updates && config.updates.channel) || null
213
+
139
214
  export const Everywhere = {
140
215
  platform,
141
216
  os,
142
217
 
218
+ // The running app's version (everywhere.yml app.version, injected by the
219
+ // shell) — e.g. an About page or footer badge. null in a plain browser tab,
220
+ // where the page came from a server, not a versioned bundle.
221
+ version: config.version || null,
222
+
143
223
  get native() {
144
224
  return platform !== "browser"
145
225
  },
@@ -168,6 +248,45 @@ export const Everywhere = {
168
248
  read() {
169
249
  return adapter.clipboardRead()
170
250
  }
251
+ },
252
+
253
+ updates: {
254
+ // True only when the shell actually has a feed to check (everywhere.yml
255
+ // updates: url + public_key made it into the shipped config).
256
+ get supported() {
257
+ return platform === "desktop" && !!config.updates
258
+ },
259
+ version: config.version || null,
260
+
261
+ // The channel the shell is actually checking. The shell injects the
262
+ // effective value (a persisted user choice overrides everywhere.yml), and
263
+ // setChannel keeps it current without a reload.
264
+ get channel() {
265
+ return updatesChannel
266
+ },
267
+
268
+ check() {
269
+ return adapter.updatesCheck()
270
+ },
271
+
272
+ // Fire-and-forget: progress arrives via on("progress"), completion as
273
+ // "ready", failures as "error"; on success the shell restarts the app.
274
+ install() {
275
+ return adapter.updatesInstall()
276
+ },
277
+
278
+ // Switch the update feed channel (e.g. "stable" -> "beta"). Resolves once
279
+ // the shell has persisted it; follow with check() to scan the new channel.
280
+ setChannel(channel) {
281
+ return adapter.updatesSetChannel(channel).then((result) => {
282
+ if (result && result.channel) updatesChannel = result.channel
283
+ return result
284
+ })
285
+ },
286
+
287
+ on(event, handler) {
288
+ return adapter.on(`update-${event}`, handler)
289
+ }
171
290
  }
172
291
  }
173
292
 
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "securerandom"
6
+ require_relative "blake2b"
7
+
8
+ module Everywhere
9
+ # Minisign-compatible Ed25519 signing, stdlib-only (OpenSSL + our Blake2b).
10
+ #
11
+ # This is the update-artifact signing scheme: `every updates keygen` mints a
12
+ # keypair, `every publish` signs each artifact, and the shell verifies with
13
+ # the `minisign-verify` Rust crate before installing an update. Files are
14
+ # byte-compatible with the reference minisign tool — keys made here sign with
15
+ # `minisign -S`, keys made there publish here, and every `.minisig` verifies
16
+ # with `minisign -V`.
17
+ #
18
+ # Formats (https://jedisct1.github.io/minisign/):
19
+ # public key "Ed" || key_id[8] || ed25519_pk[32], base64, comment line above
20
+ # secret key "Ed" || "Sc" || "B2" || salt[32] || opslimit[8 LE] ||
21
+ # memlimit[8 LE] || (key_id[8] || sk[64] || checksum[32]) XOR
22
+ # scrypt(passphrase); checksum = BLAKE2b-256("Ed"||key_id||sk)
23
+ # signature "ED" || key_id[8] || sig[64] over BLAKE2b-512(file) —
24
+ # prehashed mode, so signing never loads the artifact whole —
25
+ # plus a global sig over sig||trusted_comment
26
+ module Minisign
27
+ Error = Class.new(StandardError)
28
+ VerifyError = Class.new(Error)
29
+ PassphraseError = Class.new(Error)
30
+
31
+ UNTRUSTED = "signature from ruby_everywhere secret key"
32
+
33
+ # libsodium scryptsalsa208sha256 "interactive" cost (N=2^14, r=8, p=1 —
34
+ # 16 MB, milliseconds to derive). The reference tool defaults to
35
+ # "sensitive" (1 GB); both read whatever the key file declares, so the
36
+ # cheaper default costs no compatibility.
37
+ KDF_OPSLIMIT = 524_288
38
+ KDF_MEMLIMIT = 16_777_216
39
+
40
+ KeyPair = Struct.new(:public_key, :secret_key, :key_id_hex, keyword_init: true)
41
+
42
+ module_function
43
+
44
+ # ---- keygen --------------------------------------------------------------
45
+
46
+ def keygen(passphrase: "")
47
+ pkey = OpenSSL::PKey.generate_key("ED25519")
48
+ key_id = SecureRandom.bytes(8)
49
+ sk = pkey.raw_private_key + pkey.raw_public_key # 32-byte seed + 32-byte pk
50
+
51
+ checksum = Blake2b.digest("Ed#{key_id}#{sk}", 32)
52
+ salt = SecureRandom.bytes(32)
53
+ stream = kdf_stream(passphrase, salt, KDF_OPSLIMIT, KDF_MEMLIMIT)
54
+
55
+ secret_blob = "EdScB2#{salt}#{[KDF_OPSLIMIT, KDF_MEMLIMIT].pack("Q<Q<")}" \
56
+ "#{xor_bytes("#{key_id}#{sk}#{checksum}", stream)}"
57
+ public_blob = "Ed#{key_id}#{pkey.raw_public_key}"
58
+ id_hex = key_id_hex(key_id)
59
+
60
+ KeyPair.new(
61
+ public_key: "untrusted comment: minisign public key #{id_hex}\n#{Base64.strict_encode64(public_blob)}\n",
62
+ secret_key: "untrusted comment: minisign encrypted secret key\n#{Base64.strict_encode64(secret_blob)}\n",
63
+ key_id_hex: id_hex
64
+ )
65
+ end
66
+
67
+ # ---- signing -------------------------------------------------------------
68
+
69
+ # Returns the full .minisig content for `path`.
70
+ def sign_file(path, secret_key:, passphrase: "", trusted_comment: nil)
71
+ key_id, seed = decrypt_secret_key(secret_key, passphrase)
72
+ pkey = OpenSSL::PKey.new_raw_private_key("ED25519", seed)
73
+
74
+ signature = pkey.sign(nil, Blake2b.digest64_file(path))
75
+ trusted_comment ||= "timestamp:#{Time.now.to_i}\tfile:#{File.basename(path)}\thashed"
76
+ global = pkey.sign(nil, signature + trusted_comment.b)
77
+
78
+ "untrusted comment: #{UNTRUSTED}\n" \
79
+ "#{Base64.strict_encode64("ED#{key_id}#{signature}")}\n" \
80
+ "trusted comment: #{trusted_comment}\n" \
81
+ "#{Base64.strict_encode64(global)}\n"
82
+ end
83
+
84
+ # ---- verification --------------------------------------------------------
85
+
86
+ # `public_key` may be the two-line key file or just its base64 line.
87
+ # Raises VerifyError unless everything checks out.
88
+ def verify_file(path, signature:, public_key:)
89
+ alg, key_id, sig, trusted_comment, global = parse_signature(signature)
90
+ pk_key_id, pk = parse_public_key(public_key)
91
+
92
+ raise VerifyError, "signature key id #{key_id_hex(key_id)} doesn't match public key #{key_id_hex(pk_key_id)}" unless key_id == pk_key_id
93
+
94
+ pkey = OpenSSL::PKey.new_raw_public_key("ED25519", pk)
95
+ message = alg == "ED" ? Blake2b.digest64_file(path) : File.binread(path)
96
+ raise VerifyError, "file signature invalid for #{File.basename(path)}" unless pkey.verify(nil, sig, message)
97
+ raise VerifyError, "trusted comment signature invalid" unless pkey.verify(nil, global, sig + trusted_comment.b)
98
+
99
+ true
100
+ end
101
+
102
+ # The bare base64 line of a public key — what everywhere.yml stores and the
103
+ # shell's minisign-verify consumes.
104
+ def public_key_base64(content)
105
+ line = content.lines.map(&:strip).reject { |l| l.empty? || l.start_with?("untrusted comment:") }.first
106
+ raise Error, "no public key data found" unless line
107
+
108
+ blob = decode64(line, "public key")
109
+ raise Error, "unsupported public key format" unless blob.bytesize == 42 && blob.start_with?("Ed")
110
+
111
+ line
112
+ end
113
+
114
+ # ---- internals -----------------------------------------------------------
115
+
116
+ def parse_public_key(content)
117
+ blob = decode64(public_key_base64(content), "public key")
118
+ [blob.byteslice(2, 8), blob.byteslice(10, 32)]
119
+ end
120
+
121
+ def parse_signature(content)
122
+ lines = content.lines.map(&:chomp)
123
+ sig_b64 = lines.find { |l| !l.start_with?("untrusted comment:") && !l.strip.empty? }
124
+ trusted = lines.find { |l| l.start_with?("trusted comment: ") }&.delete_prefix("trusted comment: ")
125
+ global_b64 = lines[lines.index { |l| l.start_with?("trusted comment:") }.to_i + 1] if trusted
126
+ raise VerifyError, "malformed signature file" unless sig_b64 && trusted && global_b64
127
+
128
+ blob = decode64(sig_b64, "signature")
129
+ alg = blob.byteslice(0, 2)
130
+ raise VerifyError, "unsupported signature algorithm #{alg.inspect}" unless %w[ED Ed].include?(alg)
131
+ raise VerifyError, "malformed signature" unless blob.bytesize == 74
132
+
133
+ [alg, blob.byteslice(2, 8), blob.byteslice(10, 64), trusted, decode64(global_b64, "global signature")]
134
+ end
135
+
136
+ # -> [key_id, ed25519_seed]
137
+ def decrypt_secret_key(content, passphrase)
138
+ raise Error, "no secret key material given" if content.to_s.strip.empty?
139
+
140
+ line = content.lines.map(&:strip).reject { |l| l.empty? || l.start_with?("untrusted comment:") }.first
141
+ blob = decode64(line.to_s, "secret key")
142
+ kdf_alg = blob.byteslice(2, 2)
143
+ unless blob.bytesize == 158 && blob.start_with?("Ed") && ["Sc", "\x00\x00"].include?(kdf_alg) &&
144
+ blob.byteslice(4, 2) == "B2"
145
+ raise Error, "unsupported secret key format"
146
+ end
147
+
148
+ keynum = if kdf_alg == "Sc"
149
+ salt = blob.byteslice(6, 32)
150
+ opslimit, memlimit = blob.byteslice(38, 16).unpack("Q<Q<")
151
+ xor_bytes(blob.byteslice(54, 104), kdf_stream(passphrase, salt, opslimit, memlimit))
152
+ else
153
+ blob.byteslice(54, 104) # null KDF (minisign -W): stored in the clear
154
+ end
155
+
156
+ key_id = keynum.byteslice(0, 8)
157
+ sk = keynum.byteslice(8, 64)
158
+ checksum = keynum.byteslice(72, 32)
159
+ # Reference minisign writes an all-zero checksum for unencrypted (-W)
160
+ # keys and skips the check; a wrong checksum on an encrypted key means a
161
+ # wrong passphrase (scrypt stream decrypted to garbage).
162
+ unless (kdf_alg != "Sc" && checksum == ("\0" * 32).b) ||
163
+ OpenSSL.secure_compare(checksum, Blake2b.digest("Ed#{key_id}#{sk}", 32))
164
+ raise PassphraseError, "wrong passphrase (or corrupt secret key)"
165
+ end
166
+
167
+ [key_id, sk.byteslice(0, 32)]
168
+ end
169
+
170
+ def kdf_stream(passphrase, salt, opslimit, memlimit)
171
+ n, r, p = scrypt_params(opslimit, memlimit)
172
+ OpenSSL::KDF.scrypt(passphrase.to_s, salt: salt, N: n, r: r, p: p, length: 104)
173
+ end
174
+
175
+ # Port of libsodium's pickparams: how opslimit/memlimit (what the key file
176
+ # stores) map to scrypt's N/r/p.
177
+ def scrypt_params(opslimit, memlimit)
178
+ opslimit = 32_768 if opslimit < 32_768
179
+ r = 8
180
+ if opslimit < memlimit / 32
181
+ max_n = opslimit / (r * 4)
182
+ [1 << n_log2(max_n), r, 1]
183
+ else
184
+ max_n = memlimit / (r * 128)
185
+ n = 1 << n_log2(max_n)
186
+ max_rp = [(opslimit / 4) / n, 0x3fffffff].min
187
+ [n, r, [max_rp / r, 1].max]
188
+ end
189
+ end
190
+
191
+ def n_log2(max_n) = (1..62).find { |i| (1 << i) > max_n / 2 } || 63
192
+
193
+ def key_id_hex(key_id) = key_id.unpack1("Q<").to_s(16).upcase
194
+
195
+ def xor_bytes(a, b)
196
+ a.unpack("C*").zip(b.unpack("C*")).map { |x, y| x ^ y }.pack("C*")
197
+ end
198
+
199
+ def decode64(str, what)
200
+ Base64.strict_decode64(str.strip)
201
+ rescue ArgumentError
202
+ raise Error, "invalid base64 in #{what}"
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+ require "time"
7
+
8
+ module Everywhere
9
+ # Minimal S3 client — just the four operations `every publish` needs
10
+ # (PUT / HEAD / GET / CopyObject), signed with AWS Signature V4, stdlib only.
11
+ #
12
+ # Deliberately not aws-sdk-s3: the gem keeps a near-zero runtime dependency
13
+ # footprint, and SigV4 is a stable, testable ~150 lines. Works against any
14
+ # S3-compatible endpoint — AWS, Cloudflare R2, DigitalOcean Spaces, MinIO.
15
+ #
16
+ # Addressing: virtual-host style for AWS proper, path style for custom
17
+ # endpoints (R2/Spaces/MinIO all accept it; MinIO requires it). Override
18
+ # with force_path_style.
19
+ class S3
20
+ Error = Class.new(StandardError)
21
+
22
+ ALGORITHM = "AWS4-HMAC-SHA256"
23
+ UNSIGNED = "UNSIGNED-PAYLOAD"
24
+
25
+ attr_reader :bucket
26
+
27
+ def initialize(bucket:, region: "auto", endpoint: nil, force_path_style: nil,
28
+ access_key_id:, secret_access_key:, session_token: nil)
29
+ @bucket = bucket
30
+ @region = region.to_s.empty? ? "auto" : region
31
+ @access_key_id = access_key_id
32
+ @secret_access_key = secret_access_key
33
+ @session_token = session_token
34
+
35
+ if endpoint && !endpoint.empty?
36
+ @endpoint = URI(endpoint)
37
+ @path_style = force_path_style.nil? ? true : force_path_style
38
+ else
39
+ @endpoint = URI("https://s3.#{aws_region}.amazonaws.com")
40
+ @path_style = force_path_style || false
41
+ end
42
+ raise Error, "endpoint must be http(s)" unless %w[http https].include?(@endpoint.scheme)
43
+ end
44
+
45
+ # body: String or IO (IO must respond to #size or be paired with size:).
46
+ # sha256: precomputed hex payload digest; IOs default to UNSIGNED-PAYLOAD.
47
+ def put_object(key, body, content_type: nil, cache_control: nil, sha256: nil, size: nil)
48
+ headers = {}
49
+ headers["content-type"] = content_type if content_type
50
+ headers["cache-control"] = cache_control if cache_control
51
+ if body.is_a?(String)
52
+ sha256 ||= OpenSSL::Digest::SHA256.hexdigest(body)
53
+ headers["content-length"] = body.bytesize.to_s
54
+ else
55
+ sha256 ||= UNSIGNED
56
+ headers["content-length"] = (size || body.size).to_s
57
+ end
58
+ request(:put, key, headers: headers, body: body, payload_sha256: sha256)
59
+ true
60
+ end
61
+
62
+ # -> { size:, etag: } or nil when the key doesn't exist.
63
+ def head_object(key)
64
+ res = request(:head, key, allow_404: true)
65
+ return nil if res.code == "404"
66
+
67
+ { size: res["content-length"]&.to_i, etag: res["etag"]&.delete('"') }
68
+ end
69
+
70
+ # -> body String, or nil when the key doesn't exist.
71
+ def get_object(key)
72
+ res = request(:get, key, allow_404: true)
73
+ res.code == "404" ? nil : res.body
74
+ end
75
+
76
+ # Server-side copy within the bucket (how the -latest alias is refreshed
77
+ # without re-uploading the artifact).
78
+ def copy_object(source_key, dest_key, content_type: nil, cache_control: nil)
79
+ headers = { "x-amz-copy-source" => "/#{@bucket}/#{uri_encode_path(source_key)}" }
80
+ if content_type || cache_control
81
+ headers["x-amz-metadata-directive"] = "REPLACE"
82
+ headers["content-type"] = content_type if content_type
83
+ headers["cache-control"] = cache_control if cache_control
84
+ end
85
+ res = request(:put, dest_key, headers: headers)
86
+ # CopyObject reports some failures inside a 200 body.
87
+ raise Error, "copy #{source_key} -> #{dest_key} failed: #{res.body}" if res.body.to_s.include?("<Error>")
88
+
89
+ true
90
+ end
91
+
92
+ # ---- request plumbing ----------------------------------------------------
93
+
94
+ private
95
+
96
+ def request(method, key, headers: {}, body: nil, payload_sha256: nil, allow_404: false, attempt: 1)
97
+ uri = object_uri(key)
98
+ payload_sha256 ||= OpenSSL::Digest::SHA256.hexdigest("")
99
+ all = headers.merge(Signer.headers(
100
+ method: method, uri: uri, headers: headers, payload_sha256: payload_sha256,
101
+ access_key_id: @access_key_id, secret_access_key: @secret_access_key,
102
+ session_token: @session_token, region: aws_region
103
+ ))
104
+
105
+ req = Net::HTTP.const_get(method.capitalize).new(uri.request_uri)
106
+ all.each { |k, v| req[k] = v }
107
+ if body.is_a?(String)
108
+ req.body = body
109
+ elsif body
110
+ req.body_stream = body
111
+ end
112
+
113
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 300) do |http|
114
+ http.request(req)
115
+ end
116
+
117
+ return res if res.is_a?(Net::HTTPSuccess) || (allow_404 && res.code == "404")
118
+
119
+ if retryable?(res) && attempt < 3 && body_rewindable?(body)
120
+ sleep(attempt)
121
+ body.rewind if body.respond_to?(:rewind) && !body.is_a?(String)
122
+ return request(method, key, headers: headers, body: body, payload_sha256: payload_sha256,
123
+ allow_404: allow_404, attempt: attempt + 1)
124
+ end
125
+
126
+ raise Error, "S3 #{method.upcase} #{key} failed (HTTP #{res.code}): #{error_detail(res)}"
127
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
128
+ raise Error, "S3 #{method.upcase} #{key} failed: #{e.message}" unless attempt < 3 && body_rewindable?(body)
129
+
130
+ sleep(attempt)
131
+ body.rewind if body.respond_to?(:rewind) && !body.is_a?(String)
132
+ request(method, key, headers: headers, body: body, payload_sha256: payload_sha256,
133
+ allow_404: allow_404, attempt: attempt + 1)
134
+ end
135
+
136
+ def retryable?(res) = res.code.to_i >= 500 || res.code == "429"
137
+
138
+ def body_rewindable?(body) = body.nil? || body.is_a?(String) || body.respond_to?(:rewind)
139
+
140
+ def error_detail(res)
141
+ code = res.body.to_s[%r{<Code>([^<]+)</Code>}, 1]
142
+ msg = res.body.to_s[%r{<Message>([^<]+)</Message>}, 1]
143
+ [code, msg].compact.join(": ").then { |s| s.empty? ? res.message : s }
144
+ end
145
+
146
+ def object_uri(key)
147
+ encoded = uri_encode_path(key)
148
+ if @path_style
149
+ URI("#{@endpoint.scheme}://#{@endpoint.host}:#{@endpoint.port}/#{@bucket}/#{encoded}")
150
+ else
151
+ host = @endpoint.host.start_with?("#{@bucket}.") ? @endpoint.host : "#{@bucket}.#{@endpoint.host}"
152
+ URI("#{@endpoint.scheme}://#{host}:#{@endpoint.port}/#{encoded}")
153
+ end
154
+ end
155
+
156
+ # RFC 3986 encoding per path segment ('/' preserved) — SigV4's canonical form.
157
+ def uri_encode_path(key)
158
+ key.split("/", -1).map { |seg| Signer.uri_encode(seg) }.join("/")
159
+ end
160
+
161
+ def aws_region = @region == "auto" ? "us-east-1" : @region
162
+
163
+ # Pure SigV4 header computation, separated for direct testing against the
164
+ # published AWS test vectors.
165
+ module Signer
166
+ module_function
167
+
168
+ def headers(method:, uri:, headers:, payload_sha256:, access_key_id:, secret_access_key:,
169
+ session_token: nil, region:, time: Time.now.utc)
170
+ amz_date = time.strftime("%Y%m%dT%H%M%SZ")
171
+ date = time.strftime("%Y%m%d")
172
+
173
+ signed = {
174
+ "host" => host_header(uri),
175
+ "x-amz-content-sha256" => payload_sha256,
176
+ "x-amz-date" => amz_date
177
+ }
178
+ signed["x-amz-security-token"] = session_token if session_token
179
+ # Sign every x-amz-* header we send (S3 requires it) plus the core set.
180
+ canonical_headers = headers.select { |k, _| k.downcase.start_with?("x-amz-") || %w[range content-type content-md5 cache-control date expires].include?(k.downcase) }
181
+ .transform_keys(&:downcase).merge(signed)
182
+
183
+ sorted = canonical_headers.sort.to_h
184
+ header_list = sorted.keys.join(";")
185
+ canonical_request = [
186
+ method.to_s.upcase,
187
+ uri.path.empty? ? "/" : uri.path,
188
+ canonical_query(uri.query),
189
+ sorted.map { |k, v| "#{k}:#{v.to_s.strip}\n" }.join,
190
+ header_list,
191
+ payload_sha256
192
+ ].join("\n")
193
+
194
+ scope = "#{date}/#{region}/s3/aws4_request"
195
+ string_to_sign = [
196
+ ALGORITHM, amz_date, scope,
197
+ OpenSSL::Digest::SHA256.hexdigest(canonical_request)
198
+ ].join("\n")
199
+
200
+ k = %W[AWS4#{secret_access_key} #{date} #{region} s3 aws4_request]
201
+ .reduce { |key, data| OpenSSL::HMAC.digest("SHA256", key, data) }
202
+ signature = OpenSSL::HMAC.hexdigest("SHA256", k, string_to_sign)
203
+
204
+ signed.merge(
205
+ "authorization" => "#{ALGORITHM} Credential=#{access_key_id}/#{scope}, " \
206
+ "SignedHeaders=#{header_list}, Signature=#{signature}"
207
+ )
208
+ end
209
+
210
+ # Canonical form: every param as key=value (empty value keeps the "="),
211
+ # sorted. Callers pass unencoded simple queries; we never need decoding.
212
+ def canonical_query(query)
213
+ return "" if query.nil? || query.empty?
214
+
215
+ query.split("&").map { |pair|
216
+ k, v = pair.split("=", 2)
217
+ "#{uri_encode(k)}=#{uri_encode(v || "")}"
218
+ }.sort.join("&")
219
+ end
220
+
221
+ def host_header(uri)
222
+ default_port = uri.scheme == "https" ? 443 : 80
223
+ uri.port == default_port ? uri.host : "#{uri.host}:#{uri.port}"
224
+ end
225
+
226
+ # RFC 3986 unreserved-only encoding (AWS canonical form).
227
+ def uri_encode(str)
228
+ str.to_s.gsub(/[^A-Za-z0-9\-._~]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
229
+ end
230
+ end
231
+ end
232
+ end