envless-sdk 0.0.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.
Files changed (91) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +171 -0
  4. data/lib/envless/sdk/api_error.rb +54 -0
  5. data/lib/envless/sdk/bodies.rb +49 -0
  6. data/lib/envless/sdk/client.rb +39 -0
  7. data/lib/envless/sdk/clock.rb +24 -0
  8. data/lib/envless/sdk/connection_pool.rb +83 -0
  9. data/lib/envless/sdk/connector.rb +26 -0
  10. data/lib/envless/sdk/constants.rb +262 -0
  11. data/lib/envless/sdk/crypto/cipher.rb +58 -0
  12. data/lib/envless/sdk/crypto/codec.rb +23 -0
  13. data/lib/envless/sdk/crypto/envelope.rb +41 -0
  14. data/lib/envless/sdk/crypto/keys.rb +42 -0
  15. data/lib/envless/sdk/crypto/passphrase.rb +38 -0
  16. data/lib/envless/sdk/crypto/passphrase_rules.rb +42 -0
  17. data/lib/envless/sdk/crypto/values.rb +31 -0
  18. data/lib/envless/sdk/crypto/workspace_key.rb +40 -0
  19. data/lib/envless/sdk/crypto_messages.rb +31 -0
  20. data/lib/envless/sdk/default_client.rb +55 -0
  21. data/lib/envless/sdk/defaults.rb +32 -0
  22. data/lib/envless/sdk/envelope.rb +48 -0
  23. data/lib/envless/sdk/environment.rb +42 -0
  24. data/lib/envless/sdk/errors.rb +23 -0
  25. data/lib/envless/sdk/exchange.rb +27 -0
  26. data/lib/envless/sdk/helpers.rb +49 -0
  27. data/lib/envless/sdk/http.rb +46 -0
  28. data/lib/envless/sdk/listing.rb +54 -0
  29. data/lib/envless/sdk/messages.rb +43 -0
  30. data/lib/envless/sdk/net_http_adapter.rb +84 -0
  31. data/lib/envless/sdk/numbers.rb +44 -0
  32. data/lib/envless/sdk/options.rb +56 -0
  33. data/lib/envless/sdk/pagination.rb +31 -0
  34. data/lib/envless/sdk/paths.rb +70 -0
  35. data/lib/envless/sdk/received.rb +28 -0
  36. data/lib/envless/sdk/redirects.rb +56 -0
  37. data/lib/envless/sdk/resource.rb +50 -0
  38. data/lib/envless/sdk/resources/environments.rb +61 -0
  39. data/lib/envless/sdk/resources/invites.rb +28 -0
  40. data/lib/envless/sdk/resources/keys.rb +36 -0
  41. data/lib/envless/sdk/resources/me.rb +11 -0
  42. data/lib/envless/sdk/resources/members.rb +36 -0
  43. data/lib/envless/sdk/resources/product_access.rb +30 -0
  44. data/lib/envless/sdk/resources/products.rb +62 -0
  45. data/lib/envless/sdk/resources/projects.rb +42 -0
  46. data/lib/envless/sdk/resources/roles.rb +40 -0
  47. data/lib/envless/sdk/resources/variable_changes.rb +57 -0
  48. data/lib/envless/sdk/resources/variables.rb +49 -0
  49. data/lib/envless/sdk/resources/versions.rb +60 -0
  50. data/lib/envless/sdk/resources/webhook_deliveries.rb +33 -0
  51. data/lib/envless/sdk/resources/webhooks.rb +44 -0
  52. data/lib/envless/sdk/resources/workspace.rb +26 -0
  53. data/lib/envless/sdk/resources/workspace_domain.rb +31 -0
  54. data/lib/envless/sdk/resources/workspaces.rb +24 -0
  55. data/lib/envless/sdk/results.rb +43 -0
  56. data/lib/envless/sdk/retry_policy.rb +36 -0
  57. data/lib/envless/sdk/rotation/entry_rotator.rb +61 -0
  58. data/lib/envless/sdk/rotation/key_ring.rb +34 -0
  59. data/lib/envless/sdk/rotation/ledger.rb +36 -0
  60. data/lib/envless/sdk/rotation/runner.rb +45 -0
  61. data/lib/envless/sdk/rotation/variable_rotator.rb +38 -0
  62. data/lib/envless/sdk/text.rb +71 -0
  63. data/lib/envless/sdk/transport.rb +62 -0
  64. data/lib/envless/sdk/update_notice.rb +66 -0
  65. data/lib/envless/sdk/urls.rb +70 -0
  66. data/lib/envless/sdk/variable_names.rb +55 -0
  67. data/lib/envless/sdk/version.rb +5 -0
  68. data/lib/envless/sdk/webhooks/headers.rb +49 -0
  69. data/lib/envless/sdk/webhooks/verify.rb +58 -0
  70. data/lib/envless/sdk/wire.rb +77 -0
  71. data/lib/envless/sdk.rb +77 -0
  72. data/sig/envless/sdk/adapter.rbs +60 -0
  73. data/sig/envless/sdk/client.rbs +31 -0
  74. data/sig/envless/sdk/crypto.rbs +92 -0
  75. data/sig/envless/sdk/errors.rbs +37 -0
  76. data/sig/envless/sdk/http.rbs +31 -0
  77. data/sig/envless/sdk/pagination.rbs +81 -0
  78. data/sig/envless/sdk/results.rbs +62 -0
  79. data/sig/envless/sdk/rotation.rbs +86 -0
  80. data/sig/envless/sdk/settings.rbs +81 -0
  81. data/sig/envless/sdk/stdlib.rbs +8 -0
  82. data/sig/envless/sdk/text.rbs +52 -0
  83. data/sig/envless/sdk/transport.rbs +79 -0
  84. data/sig/envless/sdk/urls.rbs +73 -0
  85. data/sig/envless/sdk/webhooks.rbs +23 -0
  86. data/sig/envless/sdk.rbs +36 -0
  87. data/sig/generated/constants.rbs +57 -0
  88. data/sig/generated/resources.rbs +209 -0
  89. data/sig/generated/types.rbs +466 -0
  90. data/sig/manifest.yaml +3 -0
  91. metadata +134 -0
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Resources
5
+ class Webhooks < Resource
6
+ def list(limit: nil, offset: nil, search: nil, order: nil, product: nil, timeout: nil)
7
+ page(PATHS[:WEBHOOKS], COLLECTIONS[:WEBHOOKS], Listing.of(limit:, offset:, search:, order:, product:), timeout)
8
+ end
9
+
10
+ def list_all(limit: nil, offset: nil, search: nil, order: nil, product: nil, timeout: nil)
11
+ all(PATHS[:WEBHOOKS], COLLECTIONS[:WEBHOOKS], Listing.of(limit:, offset:, search:, order:, product:), timeout)
12
+ end
13
+
14
+ def iterate(limit: nil, offset: nil, search: nil, order: nil, product: nil, timeout: nil, &block)
15
+ each_item(PATHS[:WEBHOOKS], COLLECTIONS[:WEBHOOKS], Listing.of(limit:, offset:, search:, order:, product:),
16
+ timeout, &block)
17
+ end
18
+
19
+ def get(id, timeout: nil)
20
+ request(Paths.webhook_path(id), timeout:)
21
+ end
22
+
23
+ def create(body = nil, timeout: nil, **fields)
24
+ request(PATHS[:WEBHOOKS], method: HTTP_METHODS[:POST], body: body_of(body, fields), timeout:)
25
+ end
26
+
27
+ def update(id, patch = nil, timeout: nil, **fields)
28
+ request(Paths.webhook_path(id), method: HTTP_METHODS[:PATCH], body: body_of(patch, fields), timeout:)
29
+ end
30
+
31
+ def delete(id, timeout: nil)
32
+ request(Paths.webhook_path(id), method: HTTP_METHODS[:DELETE], timeout:)
33
+ end
34
+
35
+ def rotate_secret(id, timeout: nil)
36
+ request("#{Paths.webhook_path(id)}/#{SEGMENTS[:ROTATE]}", method: HTTP_METHODS[:POST], timeout:)
37
+ end
38
+
39
+ def list_event_types(timeout: nil)
40
+ request(PATHS[:WEBHOOK_EVENT_TYPES], timeout:)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Resources
5
+ class Workspace < Resource
6
+ attr_reader :domain
7
+
8
+ def initialize(transport)
9
+ @domain = WorkspaceDomain.new(transport)
10
+ super
11
+ end
12
+
13
+ def get(timeout: nil)
14
+ request(PATHS[:WORKSPACE], timeout:)
15
+ end
16
+
17
+ def update(patch = nil, timeout: nil, **fields)
18
+ request(PATHS[:WORKSPACE], method: HTTP_METHODS[:PATCH], body: body_of(patch, fields), timeout:)
19
+ end
20
+
21
+ def delete(timeout: nil)
22
+ request(PATHS[:WORKSPACE], method: HTTP_METHODS[:DELETE], timeout:)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Resources
5
+ class WorkspaceDomain < Resource
6
+ def get(timeout: nil)
7
+ domain_of(request(PATHS[:WORKSPACE_DOMAIN], timeout:))
8
+ end
9
+
10
+ def claim(hostname, timeout: nil)
11
+ domain_of(request(PATHS[:WORKSPACE_DOMAIN], method: HTTP_METHODS[:POST], body: {"hostname" => hostname},
12
+ timeout:))
13
+ end
14
+
15
+ def verify(reattempt: false, timeout: nil)
16
+ domain_of(request(PATHS[:WORKSPACE_DOMAIN_VERIFY], method: HTTP_METHODS[:POST],
17
+ body: {"reattempt" => reattempt == true}, timeout:))
18
+ end
19
+
20
+ def remove(timeout: nil)
21
+ request(PATHS[:WORKSPACE_DOMAIN], method: HTTP_METHODS[:DELETE], timeout:)
22
+ end
23
+
24
+ private
25
+
26
+ def domain_of(data)
27
+ data.is_a?(Hash) ? data[:domain] : nil
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Resources
5
+ class Workspaces < Resource
6
+ def list(limit: nil, offset: nil, search: nil, order: nil, product: nil, timeout: nil)
7
+ page(PATHS[:WORKSPACES], COLLECTIONS[:WORKSPACES], Listing.of(limit:, offset:, search:, order:, product:), timeout)
8
+ end
9
+
10
+ def list_all(limit: nil, offset: nil, search: nil, order: nil, product: nil, timeout: nil)
11
+ all(PATHS[:WORKSPACES], COLLECTIONS[:WORKSPACES], Listing.of(limit:, offset:, search:, order:, product:), timeout)
12
+ end
13
+
14
+ def iterate(limit: nil, offset: nil, search: nil, order: nil, product: nil, timeout: nil, &block)
15
+ each_item(PATHS[:WORKSPACES], COLLECTIONS[:WORKSPACES], Listing.of(limit:, offset:, search:, order:, product:),
16
+ timeout, &block)
17
+ end
18
+
19
+ def create(body = nil, timeout: nil, **fields)
20
+ request(PATHS[:WORKSPACES], method: HTTP_METHODS[:POST], body: body_of(body, fields), timeout:)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ Page = Data.define(:items, :pagination)
5
+
6
+ class Page
7
+ def has_more?
8
+ Wire.truthy?(pagination[:hasMore])
9
+ end
10
+
11
+ def total_count
12
+ pagination[:totalCount]
13
+ end
14
+ end
15
+
16
+ ProductAccessPage = Data.define(:items, :pagination, :is_private)
17
+
18
+ class ProductAccessPage
19
+ def private?
20
+ is_private
21
+ end
22
+
23
+ def has_more?
24
+ Wire.truthy?(pagination[:hasMore])
25
+ end
26
+
27
+ def total_count
28
+ pagination[:totalCount]
29
+ end
30
+ end
31
+
32
+ Validation = Data.define(:valid, :error)
33
+
34
+ class Validation
35
+ def valid?
36
+ valid
37
+ end
38
+ end
39
+
40
+ RotationIssue = Data.define(:scope, :id, :field, :reason)
41
+
42
+ RotationResult = Data.define(:variables, :version_entries, :skipped, :failures)
43
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module RetryPolicy
5
+ MILLISECONDS = 1000.0
6
+
7
+ module_function
8
+
9
+ def retry_after_of(header)
10
+ header.nil? ? Float::NAN : Numbers.js_number(header)
11
+ end
12
+
13
+ def delay(status, verb, attempt, max_retries, retry_after)
14
+ return nil if attempt >= max_retries
15
+ return nil unless Constants::RETRYABLE_STATUSES.include?(status) && Constants::RETRYABLE_METHODS.include?(verb)
16
+
17
+ asked = retry_after.finite? && retry_after.positive?
18
+ wait = asked ? retry_after * MILLISECONDS : (1 << (attempt + 1)) * Constants::RETRY[:BASE_BACKOFF_MS]
19
+
20
+ [wait, Constants::RETRY[:MAX_BACKOFF_MS]].min / MILLISECONDS
21
+ end
22
+
23
+ def settle(verb, attempt, max_retries, response)
24
+ envelope = Envelope.read(response.status, response.reason, response.body)
25
+ return Reply.new(data: envelope[:data]) if Envelope.success?(response.status, envelope)
26
+
27
+ retry_after = retry_after_of(response.header(Constants::HEADER_KEYS[:RETRY_AFTER]))
28
+ wait = delay(response.status, verb, attempt, max_retries, retry_after)
29
+ raise Envelope.api_error(response.status, response.headers, envelope, retry_after) if wait.nil?
30
+
31
+ wait
32
+ end
33
+ end
34
+
35
+ Reply = Data.define(:data)
36
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Rotation
5
+ class EntryRotator
6
+ SCOPE = Constants::ROTATION_SCOPES[:VERSION_ENTRY]
7
+ FIELDS = Constants::ROTATION_FIELDS
8
+
9
+ def initialize(client, ring, ledger, scope, product)
10
+ @client = client
11
+ @ring = ring
12
+ @ledger = ledger
13
+ @scope = scope
14
+ @product = product
15
+ end
16
+
17
+ def rotate(batch)
18
+ rewritten = batch.filter_map { |entry| attempt(entry) }
19
+ rewritten.empty? ? 0 : write(rewritten)
20
+ end
21
+
22
+ private
23
+
24
+ def attempt(entry)
25
+ rotated(entry)
26
+ rescue => error
27
+ @ledger.failed(SCOPE, Ledger.identifier(entry), error)
28
+ nil
29
+ end
30
+
31
+ def write(rewritten)
32
+ project, environment = @scope
33
+ result = @client.versions.reencrypt_entries(project, environment, rewritten, product: @product)
34
+ updated = result.is_a?(Hash) ? result[:updated] : nil
35
+ updated.is_a?(Integer) ? updated : 0
36
+ rescue => error
37
+ rewritten.each { |written| @ledger.failed(SCOPE, written[:id], error) }
38
+ 0
39
+ end
40
+
41
+ def rotated(entry)
42
+ id = Ledger.identifier(entry)
43
+ value = entry[:value]
44
+ return strand(entry, id) unless Wire.truthy?(value) && @ledger.rotatable?(SCOPE, id, FIELDS[:VALUE], value)
45
+
46
+ sealed = @ring.rotate(value)
47
+ default = entry[:defaultValue]
48
+ rotatable = Wire.truthy?(default) && @ledger.rotatable?(SCOPE, id, FIELDS[:DEFAULT_VALUE], default)
49
+
50
+ {id:, value: sealed, defaultValue: rotatable ? @ring.rotate(default) : nil}
51
+ end
52
+
53
+ def strand(entry, id)
54
+ default = entry[:defaultValue]
55
+ field = FIELDS[:DEFAULT_VALUE]
56
+ @ledger.stranded(SCOPE, id, field) if Wire.truthy?(default) && @ledger.rotatable?(SCOPE, id, field, default)
57
+ nil
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Rotation
5
+ class KeyRing
6
+ def initialize(workspace_unique_id, current, following)
7
+ @workspace = workspace_unique_id
8
+ @current = current
9
+ @following = following
10
+ @opening = {}
11
+ @sealing = nil
12
+ end
13
+
14
+ def rotate(value)
15
+ plaintext = Crypto::Cipher.decrypt(opening_key(Crypto::Envelope.parse(value).iterations), value)
16
+ Crypto::Cipher.encrypt_with_key(sealing_key, plaintext)
17
+ end
18
+
19
+ private
20
+
21
+ def opening_key(iterations)
22
+ @opening[iterations] ||= if iterations.nil?
23
+ Crypto::Keys.derive_workspace_key(@current, @workspace)
24
+ else
25
+ Crypto::Keys.derive_key(@current, Crypto::Keys.workspace_salt(@workspace), iterations)
26
+ end
27
+ end
28
+
29
+ def sealing_key
30
+ @sealing ||= Crypto::Keys.derive_workspace_key(@following, @workspace)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Rotation
5
+ class Ledger
6
+ def initialize
7
+ @skipped = []
8
+ @failures = []
9
+ end
10
+
11
+ def rotatable?(scope, id, field, value)
12
+ return true if Crypto::Values.ciphertext?(value)
13
+
14
+ @skipped << RotationIssue.new(scope:, id:, field:, reason: Constants::ROTATION_MESSAGES[:NOT_CIPHERTEXT])
15
+ false
16
+ end
17
+
18
+ def stranded(scope, id, field)
19
+ @failures << RotationIssue.new(scope:, id:, field:, reason: Constants::ROTATION_MESSAGES[:DEFAULT_STRANDED])
20
+ end
21
+
22
+ def failed(scope, id, error)
23
+ @failures << RotationIssue.new(scope:, id:, field: nil, reason: error.message)
24
+ end
25
+
26
+ def result(variables, version_entries)
27
+ RotationResult.new(variables:, version_entries:, skipped: @skipped.dup, failures: @failures.dup)
28
+ end
29
+
30
+ def self.identifier(item)
31
+ id = item.is_a?(Hash) ? item[:id] : nil
32
+ id.is_a?(String) ? id : ""
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Rotation
5
+ class Runner
6
+ def initialize(client:, workspace_unique_id:, from:, to:, project:, environment:, product: nil)
7
+ ring = KeyRing.new(workspace_unique_id, from, to)
8
+ @client = client
9
+ @following = to
10
+ @scope = [project, environment]
11
+ @product = product
12
+ @ledger = Ledger.new
13
+ @variables = VariableRotator.new(client, ring, @ledger, product)
14
+ @entries = EntryRotator.new(client, ring, @ledger, @scope, product)
15
+ end
16
+
17
+ def run
18
+ strength = Crypto::Passphrase.strength(@following)
19
+ raise ArgumentError, format(CryptoMessages::WEAK_ROTATION, error: strength.error) unless strength.valid?
20
+
21
+ project, environment = @scope
22
+ variables = @client.variables.list_all(project, environment, product: @product)
23
+ entries = @client.versions.list_all_entries(project, environment, product: @product)
24
+ batches = entries.each_slice(Constants::REENCRYPT_BATCH_SIZE).to_a
25
+
26
+ rotated, version_entries = totals(variables, batches)
27
+ @ledger.result(rotated, version_entries)
28
+ end
29
+
30
+ private
31
+
32
+ def totals(variables, batches)
33
+ rotated = 0
34
+ version_entries = 0
35
+
36
+ [variables.size, batches.size].max.times do |index|
37
+ rotated += @variables.rotate(variables.fetch(index)) if index < variables.size
38
+ version_entries += @entries.rotate(batches.fetch(index)) if index < batches.size
39
+ end
40
+
41
+ [rotated, version_entries]
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Rotation
5
+ class VariableRotator
6
+ SCOPE = Constants::ROTATION_SCOPES[:VARIABLE]
7
+ FIELDS = [Constants::ROTATION_FIELDS[:VALUE], Constants::ROTATION_FIELDS[:DEFAULT_VALUE]].freeze
8
+
9
+ def initialize(client, ring, ledger, product)
10
+ @client = client
11
+ @ring = ring
12
+ @ledger = ledger
13
+ @product = product
14
+ end
15
+
16
+ def rotate(variable)
17
+ rotated(variable)
18
+ rescue => error
19
+ @ledger.failed(SCOPE, Ledger.identifier(variable), error)
20
+ 0
21
+ end
22
+
23
+ private
24
+
25
+ def rotated(variable)
26
+ id = Ledger.identifier(variable)
27
+ patch = FIELDS.filter_map do |field|
28
+ value = variable[field.to_sym]
29
+ [field.to_sym, @ring.rotate(value)] if Wire.truthy?(value) && @ledger.rotatable?(SCOPE, id, field, value)
30
+ end
31
+ return 0 if patch.empty?
32
+
33
+ @client.variables.update_by_id(id, product: @product, **patch.to_h)
34
+ 1
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Text
5
+ BYTE_ORDER_MARK = ""
6
+ HTTP_WHITESPACE = " \t\r\n"
7
+ JS_WHITESPACE = [0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0xA0, 0x1680, *(0x2000..0x200A).to_a, 0x2028, 0x2029, 0x202F,
8
+ 0x205F, 0x3000, 0xFEFF].pack("U*").freeze
9
+ JS_TRIM = /\A[#{Regexp.escape(JS_WHITESPACE)}]+|[#{Regexp.escape(JS_WHITESPACE)}]+\z/
10
+ HTTP_TRIM = /\A[ \t\r\n]+|[ \t\r\n]+\z/
11
+ TOKEN = /\A[\x21-\x7e]+\z/
12
+ HEADER_VALUE = /\A[\x20-\x7e]*\z/
13
+ TRANSCODABLE = [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].freeze
14
+
15
+ module_function
16
+
17
+ def utf8(text)
18
+ converted = if TRANSCODABLE.include?(text.encoding)
19
+ text.dup.force_encoding(Encoding::UTF_8)
20
+ else
21
+ text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
22
+ end
23
+
24
+ converted.valid_encoding? ? converted : converted.scrub
25
+ end
26
+
27
+ def strict(text, name)
28
+ raise ArgumentError, format(CryptoMessages::TEXT_SHAPE, name:) unless text.is_a?(String)
29
+
30
+ converted = TRANSCODABLE.include?(text.encoding) ? text.dup.force_encoding(Encoding::UTF_8) : text.encode(Encoding::UTF_8)
31
+ raise ArgumentError, format(CryptoMessages::UTF8_SHAPE, name:) unless converted.valid_encoding?
32
+
33
+ converted
34
+ rescue EncodingError
35
+ raise ArgumentError, format(CryptoMessages::UTF8_SHAPE, name:)
36
+ end
37
+
38
+ def decode(bytes)
39
+ utf8(bytes.b).delete_prefix(BYTE_ORDER_MARK)
40
+ end
41
+
42
+ def js_trim(text)
43
+ utf8(text).gsub(JS_TRIM, "")
44
+ end
45
+
46
+ def http_trim(text)
47
+ utf8(text).gsub(HTTP_TRIM, "")
48
+ end
49
+
50
+ def token?(text)
51
+ TOKEN.match?(text)
52
+ end
53
+
54
+ def header_value?(text)
55
+ HEADER_VALUE.match?(text)
56
+ end
57
+
58
+ def utf16_units(text)
59
+ utf8(text).codepoints.flat_map do |point|
60
+ next [point] if point < 0x10000
61
+
62
+ offset = point - 0x10000
63
+ [0xD800 + (offset >> 10), 0xDC00 + (offset & 0x3FF)]
64
+ end
65
+ end
66
+
67
+ def utf16_length(text)
68
+ utf8(text).codepoints.sum { |point| (point < 0x10000) ? 1 : 2 }
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ class Transport
5
+ attr_reader :base_url, :max_retries, :timeout, :user_agent
6
+
7
+ def initialize(token:, base_url:, adapter: nil, max_retries: nil, timeout: nil, user_agent: nil,
8
+ disable_update_notice: false)
9
+ @token = token
10
+ @base_url = Urls.valid_base_url(base_url)
11
+ @max_retries = Options.valid_retries(max_retries)
12
+ @timeout = timeout.nil? ? Defaults::TIMEOUT : Options.valid_timeout(timeout)
13
+ @user_agent = Options.user_agent(user_agent)
14
+ @owned = adapter ? nil : NetHttpAdapter.new
15
+ @adapter = @owned || Options.adapter(adapter)
16
+ UpdateNotice.check unless disable_update_notice
17
+ freeze
18
+ end
19
+
20
+ def request(path, method: Constants::HTTP_METHODS[:GET], query: nil, body: nil, timeout: nil)
21
+ verb = Options.verb(method)
22
+ url = Urls.build_url(@base_url, path, query)
23
+ content = body.nil? ? nil : Wire.encode(body)
24
+ seconds = timeout.nil? ? @timeout : Options.valid_timeout(timeout)
25
+ outbound = HttpRequest.new(method: verb, url:, headers: headers(!content.nil?), body: content, timeout: seconds)
26
+ attempt = 0
27
+
28
+ loop do
29
+ outcome = RetryPolicy.settle(verb, attempt, @max_retries, Exchange.call(@adapter, outbound, seconds, @base_url))
30
+ return outcome.data if outcome.is_a?(Reply)
31
+
32
+ attempt += 1
33
+ pause(outcome)
34
+ end
35
+ end
36
+
37
+ def close
38
+ @owned&.close
39
+ nil
40
+ end
41
+
42
+ def inspect
43
+ "#<#{self.class.name} base_url=#{@base_url.inspect}>"
44
+ end
45
+
46
+ private
47
+
48
+ def headers(with_body)
49
+ headers = {
50
+ Constants::HEADER_KEYS[:AUTHORIZATION] => "Bearer #{@token}",
51
+ Constants::HEADER_KEYS[:ACCEPT] => Constants::CONTENT_TYPES[:JSON],
52
+ Constants::HEADER_KEYS[:USER_AGENT] => @user_agent
53
+ }
54
+ headers[Constants::HEADER_KEYS[:CONTENT_TYPE]] = Constants::CONTENT_TYPES[:JSON] if with_body
55
+ headers
56
+ end
57
+
58
+ def pause(seconds)
59
+ sleep(seconds)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module UpdateNotice
5
+ @checked = false
6
+ @lock = Mutex.new
7
+
8
+ class << self
9
+ def check
10
+ return nil unless claim
11
+
12
+ Thread.new { announce }
13
+ nil
14
+ end
15
+
16
+ def claim
17
+ @lock.synchronize do
18
+ wanted = !@checked && !disabled?
19
+ @checked = true
20
+ wanted
21
+ end
22
+ end
23
+
24
+ def disabled?
25
+ !ENV.fetch(Defaults::UPDATE_NOTICE_ENV_DISABLE, "").empty? || !$stdout.tty?
26
+ rescue IOError, NoMethodError
27
+ true
28
+ end
29
+
30
+ def announce
31
+ latest = latest_version
32
+ warn(notice(latest)) if latest && newer?(latest, VERSION)
33
+ rescue
34
+ nil
35
+ end
36
+
37
+ def latest_version
38
+ seconds = Defaults::UPDATE_NOTICE_TIMEOUT
39
+ response = Net::HTTP.start(Defaults::UPDATE_NOTICE_HOST, 443, use_ssl: true, open_timeout: seconds,
40
+ read_timeout: seconds, write_timeout: seconds) { |http| http.request_get(Defaults::UPDATE_NOTICE_PATH) }
41
+ return nil unless response.is_a?(Net::HTTPSuccess)
42
+
43
+ version = JSON.parse(response.body.to_s)["version"]
44
+ version.is_a?(String) ? version : nil
45
+ end
46
+
47
+ def newer?(candidate, current)
48
+ left = parts(candidate)
49
+ right = parts(current)
50
+ return false if left.nil? || right.nil?
51
+
52
+ (left <=> right) == 1
53
+ end
54
+
55
+ def parts(value)
56
+ numbers = value.to_s.strip.delete_prefix("v").split(".").map { |part| Integer(part, 10, exception: false) }
57
+ (numbers.size == 3 && numbers.none?(&:nil?)) ? numbers.compact : nil
58
+ end
59
+
60
+ def notice(latest)
61
+ format(Messages::UPDATE_AVAILABLE, icon: Defaults::UPDATE_NOTICE_ICON, package: Defaults::UPDATE_NOTICE_PACKAGE,
62
+ latest:, current: VERSION, command: Defaults::UPDATE_NOTICE_COMMAND, changelog: Defaults::UPDATE_NOTICE_CHANGELOG_URL)
63
+ end
64
+ end
65
+ end
66
+ end