envless-env 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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +13 -0
  3. data/README.md +117 -0
  4. data/exe/envless-env +6 -0
  5. data/lib/envless/env/api.rb +35 -0
  6. data/lib/envless/env/binding.rb +26 -0
  7. data/lib/envless/env/cache.rb +54 -0
  8. data/lib/envless/env/cache_writer.rb +34 -0
  9. data/lib/envless/env/coerce.rb +53 -0
  10. data/lib/envless/env/credentials.rb +27 -0
  11. data/lib/envless/env/crypto.rb +35 -0
  12. data/lib/envless/env/decryptor.rb +35 -0
  13. data/lib/envless/env/environment_writer.rb +43 -0
  14. data/lib/envless/env/errors.rb +47 -0
  15. data/lib/envless/env/files.rb +44 -0
  16. data/lib/envless/env/injected.rb +50 -0
  17. data/lib/envless/env/isolated.rb +45 -0
  18. data/lib/envless/env/keyring.rb +54 -0
  19. data/lib/envless/env/load.rb +5 -0
  20. data/lib/envless/env/loader.rb +83 -0
  21. data/lib/envless/env/messages.rb +36 -0
  22. data/lib/envless/env/override.rb +17 -0
  23. data/lib/envless/env/rails.rb +21 -0
  24. data/lib/envless/env/serialisation.rb +50 -0
  25. data/lib/envless/env/settings.rb +39 -0
  26. data/lib/envless/env/store.rb +91 -0
  27. data/lib/envless/env/target.rb +19 -0
  28. data/lib/envless/env/types/collect.rb +59 -0
  29. data/lib/envless/env/types/command.rb +64 -0
  30. data/lib/envless/env/types/declared.rb +43 -0
  31. data/lib/envless/env/types/render.rb +52 -0
  32. data/lib/envless/env/types.rb +6 -0
  33. data/lib/envless/env/update_notice.rb +64 -0
  34. data/lib/envless/env/values.rb +90 -0
  35. data/lib/envless/env/version.rb +7 -0
  36. data/lib/envless/env.rb +63 -0
  37. data/sig/envless/env/loading.rbs +84 -0
  38. data/sig/envless/env/settings.rbs +84 -0
  39. data/sig/envless/env/state.rbs +70 -0
  40. data/sig/envless/env/types.rbs +56 -0
  41. data/sig/envless/env/values.rbs +75 -0
  42. data/sig/envless/env.rbs +66 -0
  43. data/sig/manifest.yaml +2 -0
  44. metadata +101 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3005e3dc4471d987d8960f6007d26aff6309c424a6f77cdfa5bc6150e7ea504c
4
+ data.tar.gz: 30efa0f02503e3524d6b84c7edadc3fa739998b1bb0bc68302bffa68b31c1eb9
5
+ SHA512:
6
+ metadata.gz: 2e158c360a4c7e057ec90651a1fa7fb24263b486071f812f37f253f9779cb5654c6b5ff32a614c71d72307fc9f4f177668042f6397ae3c898d2ee5181aba2410
7
+ data.tar.gz: b961eaaecfc259e2272da9f458d87ccb9d8ff76010cbf728c9f51332a0f1da1d700c36492bc4f7c973541337e6657e7bfeeb2b2fceb2d7ed4b3c9df1a875e6b9
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1
4
+
5
+ The first release of the Ruby runtime, matching `@goenvless/env` as it stands in this repository. `rake parity` runs both packages through the same scenarios against one fake API and fails on any difference in the requests, the values, the types, the errors, the process environment, the warnings or the cache files.
6
+
7
+ ### Added
8
+
9
+ `Envless::Env.load` fetches an environment's ciphertext with an `ev_sk_` machine key, decrypts it with `ENVLESS_KEY` or `ENVLESS_PASSPHRASE`, caches the ciphertext for 15 minutes in the files `@goenvless/env` uses, and fills `ENV` without overwriting anything already set or writing a name that changes how a process executes code, `RUBYOPT`, `RUBYLIB`, `BUNDLE_GEMFILE`, `GEM_HOME` and `GEM_PATH` included. Inside `envless run` it adopts the injected values with no credentials and no request. `require "envless/env/load"` loads at require.
10
+
11
+ `Envless.env` reads every variable coerced to its type, `optional` reads `nil` for a missing name, and `client` sees only the client variables, with `snapshot` for templates. The views refuse to be dumped, marshalled or printed with their values. `Envless::Env.override` layers values for tests.
12
+
13
+ `require "envless/env/rails"` adds a Railtie that loads before the app is configured and an `envless` view helper. `Envless::Env.fetch_environment` loads with explicit keywords for serverless functions, and the crypto helpers carry the encryption recipe. Every method has an RBS signature, and `bundle exec envless-env types` writes `sig/envless-env.rbs` from your variables, so Steep types every read by name.
data/README.md ADDED
@@ -0,0 +1,117 @@
1
+ <div align='center'>
2
+ <a href='https://envless.cloud'>
3
+ <img
4
+ src='https://cdn-prod.envless.cloud/assets/npm/logo.svg'
5
+ alt='Envless Logo'
6
+ width='180'
7
+ />
8
+ </a>
9
+
10
+ <br />
11
+ </div>
12
+
13
+ <p align='center'>
14
+ End-to-end encrypted environment variables for teams. Load them into a Ruby app at boot, decrypted on your machine.
15
+ </p>
16
+
17
+ <p align='center'>
18
+ <a href='https://envless.cloud'>
19
+ <b>
20
+ Website
21
+ </b>
22
+ </a>
23
+ •
24
+ <a href='https://envless.cloud/dashboard'>
25
+ <b>
26
+ Dashboard
27
+ </b>
28
+ </a>
29
+ •
30
+ <a href='https://envless.cloud/docs/env-ruby/overview'>
31
+ <b>
32
+ Documentation
33
+ </b>
34
+ </a>
35
+ •
36
+ <a href='https://status.envless.cloud'>
37
+ <b>
38
+ Services Status
39
+ </b>
40
+ </a>
41
+ </p>
42
+
43
+ <br />
44
+
45
+ ## Intro to the Ruby runtime
46
+
47
+ `envless-env` loads your Envless environment into a Ruby process when it starts. It fetches the ciphertext with a machine key, decrypts it in the process, fills `ENV` with every name that is safe to set, and gives your code `Envless.env`, which reads each variable coerced to the type you set in Envless. It is the Ruby twin of `@goenvless/env`: the same loader, the same on-disk cache and the same rules for the process environment.
48
+
49
+ ### Installing
50
+ ```bash
51
+ bundle add envless-env
52
+ ```
53
+
54
+ Or `gem install envless-env`. It needs Ruby 3.2 or newer and depends on `envless-sdk` only.
55
+
56
+ ### Loading
57
+ ```ruby
58
+ require "envless/env/load"
59
+
60
+ database_url = Envless.env[:DATABASE_URL]
61
+ port = Envless.env[:PORT]
62
+ ```
63
+
64
+ `envless/env/load` loads when it is required, so require it first. `require "envless/env"` loads nothing until you call `Envless::Env.load`, which returns a plain Hash of every value and is memoised, so later calls return the same values without fetching. `Envless::Env.load(revalidate: true)` fetches again.
65
+
66
+ The loader reads `ENVLESS_TOKEN` (an `ev_sk_` machine key), `ENVLESS_KEY` or `ENVLESS_PASSPHRASE` to decrypt, and the product, project and environment from `ENVLESS_PRODUCT`, `ENVLESS_PROJECT` and `ENVLESS_ENV` or the `.envless` file `envless link` writes. The ciphertext is cached under `~/.envless/cache` for 15 minutes, in the same files `@goenvless/env` uses. Inside `envless run`, everything comes from the environment the CLI injected, with no credentials and no request.
67
+
68
+ Values are written into `ENV` without overwriting anything already set, and names that change how a process executes code, such as `PATH`, `LD_PRELOAD`, `RUBYOPT` and `RUBYLIB`, are never written there at all.
69
+
70
+ ### Reading
71
+ ```ruby
72
+ require "envless/env/load"
73
+
74
+ port = Envless.env.fetch(:PORT, 3000)
75
+ sentry_dsn = Envless.env.optional[:SENTRY_DSN]
76
+ public_values = Envless.env.client.snapshot
77
+ ```
78
+
79
+ A `number` reads back as an Integer, or a Float when it has a fraction, and a `boolean` as `true` or `false`. A missing name raises `Envless::Env::MissingVariableError` unless you read it through `optional`. The `client` view only sees the variables marked client in Envless. `Envless.env` never prints its values and refuses to be dumped or marshalled.
80
+
81
+ ### Types
82
+ ```bash
83
+ bundle exec envless-env types
84
+ ```
85
+
86
+ Writes `sig/envless-env.rbs` from your variable names, types and visibility, so Steep types `Envless.env.DATABASE_URL` as a `String` and catches a misspelt name or a server variable read through `client`. Under `envless run` it reads the injected manifest and needs no key.
87
+
88
+ ### Rails
89
+ ```ruby
90
+ gem "envless-env", require: "envless/env/rails"
91
+ ```
92
+
93
+ The Railtie loads before the app is configured, so `config/database.yml` and every initializer see the values, and views get an `envless` helper with the client variables.
94
+
95
+ ### Tests
96
+ ```ruby
97
+ require "envless/env"
98
+
99
+ Envless::Env.override(API_URL: "https://api.example.test") do
100
+ puts Envless.env[:API_URL]
101
+ end
102
+ ```
103
+
104
+ ### Serverless
105
+ ```ruby
106
+ require "envless/env"
107
+
108
+ values = Envless::Env.fetch_environment(token: ENV.fetch("ENVLESS_TOKEN"),
109
+ project: "checkout-api", environment: "production",
110
+ key: ENV.fetch("ENVLESS_KEY"))
111
+
112
+ puts values.size
113
+ ```
114
+
115
+ `fetch_environment` takes every input as a keyword and touches nothing on disk or in `ENV`.
116
+
117
+ Every load failure raises `Envless::Env::LoadError`, and every error inherits `Envless::Error` from `envless-sdk`. The gem ships RBS signatures: add `library "envless-sdk", "envless-env"` to your Steepfile.
data/exe/envless-env ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "envless/env/types"
5
+
6
+ exit Envless::Env::Types::Command.call(ARGV)
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Env
5
+ class Api
6
+ FAILURES = [Envless::Error, ArgumentError].freeze
7
+
8
+ def initialize(token, endpoint: nil, adapter: nil)
9
+ @client = Envless::Client.new(token:, base_url: endpoint, adapter:, user_agent: Settings::USER_AGENT,
10
+ disable_update_notice: true)
11
+ rescue *FAILURES => error
12
+ raise LoadError, error.message
13
+ end
14
+
15
+ def workspace_id
16
+ id = @client.workspace.get[:id]
17
+ id.is_a?(String) ? id : nil
18
+ rescue *FAILURES => error
19
+ raise LoadError, error.message
20
+ end
21
+
22
+ def variables(target)
23
+ listed = @client.variables.list_all(target.project, target.environment, product: target.product,
24
+ limit: Settings::PAGE_LIMIT)
25
+ listed.map do |variable|
26
+ {name: variable[:name], value: variable[:value] || "", type: variable[:type], visibility: variable[:visibility]}
27
+ end
28
+ end
29
+
30
+ def close
31
+ @client.close
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Env
5
+ Binding = Data.define(:workspace, :product, :project, :environments)
6
+
7
+ class Binding
8
+ def self.read(directory = Dir.pwd)
9
+ parsed = Files.json(File.join(directory, Settings::ENVLESS_FILE))
10
+ return nil if parsed.nil?
11
+
12
+ workspace = parsed["workspace"]
13
+ project = parsed["project"]
14
+ environments = parsed["environments"]
15
+ return nil unless Files.text?(workspace) && Files.text?(project) && listed?(environments)
16
+
17
+ product = parsed["product"]
18
+ new(workspace:, product: Files.text?(product) ? product : nil, project:, environments:)
19
+ end
20
+
21
+ def self.listed?(environments)
22
+ environments.is_a?(Array) && !environments.empty? && environments.all? { |slug| Files.text?(slug) }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Env
5
+ module Cache
6
+ module_function
7
+
8
+ def path(target)
9
+ directory = Files.config_dir
10
+ return nil if directory.nil?
11
+
12
+ product = target.product ? sanitize(target.product) : Settings::CACHE_NO_PRODUCT
13
+ name = "#{product}__#{sanitize(target.project)}__#{sanitize(target.environment)}.json"
14
+ name = "#{sanitize(target.workspace)}__#{name}" if target.workspace
15
+ File.join(directory, Settings::CACHE_DIR, name)
16
+ end
17
+
18
+ def sanitize(slug)
19
+ Files.utf8(slug).gsub(Settings::CACHE_UNSAFE_CHARACTERS) { |character| (character.ord > 0xFFFF) ? "__" : "_" }
20
+ end
21
+
22
+ def read(target)
23
+ location = path(target)
24
+ parsed = location && Files.json(location)
25
+ return nil if parsed.nil? || !parsed["variables"].is_a?(Array)
26
+
27
+ variables = parsed["variables"].map { |entry| entry_of(entry) }
28
+ return nil if variables.any?(&:nil?)
29
+
30
+ workspace = parsed["workspace"].is_a?(String) ? parsed["workspace"] : nil
31
+ return nil unless workspace == target.workspace
32
+
33
+ {variables: variables.compact, cached_at: parsed["cachedAt"].is_a?(String) ? parsed["cachedAt"] : ""}
34
+ end
35
+
36
+ def entry_of(entry)
37
+ return nil unless entry.is_a?(Hash) && entry["name"].is_a?(String) && entry["value"].is_a?(String)
38
+
39
+ type = entry["type"]
40
+ visibility = entry["visibility"]
41
+ {name: entry["name"], value: entry["value"], type: type.is_a?(String) ? type : nil,
42
+ visibility: visibility.is_a?(String) ? visibility : nil}
43
+ end
44
+
45
+ def fresh?(cached_at, now = Time.now)
46
+ stamped = Time.iso8601(cached_at)
47
+ age = now - stamped
48
+ age >= 0 && age < Settings::CACHE_MAX_AGE
49
+ rescue ArgumentError
50
+ false
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Env
5
+ module CacheWriter
6
+ module_function
7
+
8
+ def write(target, variables, now = Time.now)
9
+ location = Cache.path(target)
10
+ return if location.nil?
11
+
12
+ directory = File.dirname(location)
13
+ FileUtils.mkdir_p(directory, mode: Settings::DIR_MODE)
14
+ File.chmod(Settings::DIR_MODE, File.dirname(directory))
15
+ File.chmod(Settings::DIR_MODE, directory)
16
+ replace(location, JSON.pretty_generate(payload(target, variables, now)))
17
+ end
18
+
19
+ def payload(target, variables, now)
20
+ entries = variables.map { |variable| variable.slice(:name, :value, :type, :visibility).compact }
21
+ written = {variables: entries, cachedAt: now.utc.iso8601(3)}
22
+ target.workspace ? written.merge(workspace: target.workspace) : written
23
+ end
24
+
25
+ def replace(location, content)
26
+ staged = "#{location}.tmp"
27
+ File.open(staged, File::WRONLY | File::CREAT | File::TRUNC, Settings::FILE_MODE) { |file| file.write(content) }
28
+ File.chmod(Settings::FILE_MODE, staged)
29
+ File.rename(staged, location)
30
+ File.chmod(Settings::FILE_MODE, location)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Env
5
+ module Coerce
6
+ WHITESPACE = "\t\n\v\f\r    - 

   "
7
+ DECIMAL = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\z/
8
+ INFINITY = /\A([+-]?)Infinity\z/
9
+ RADIX = /\A0([xXoObB])([0-9a-fA-F]+)\z/
10
+ BASES = {"x" => 16, "o" => 8, "b" => 2}.freeze
11
+ TRUE_VALUES = %w[true 1].freeze
12
+
13
+ module_function
14
+
15
+ def call(value, type)
16
+ case type
17
+ when Settings::VAR_TYPES[:NUMBER] then number(value)
18
+ when Settings::VAR_TYPES[:BOOLEAN] then TRUE_VALUES.include?(value)
19
+ else value
20
+ end
21
+ end
22
+
23
+ def number(value)
24
+ text = trim(value)
25
+ return 0 if text.empty?
26
+
27
+ infinity = INFINITY.match(text)
28
+ return (infinity[1] == "-") ? -Float::INFINITY : Float::INFINITY if infinity
29
+
30
+ radix = RADIX.match(text)
31
+ return radix_number(radix[2], BASES.fetch(radix[1].downcase)) if radix
32
+ return Float::NAN unless DECIMAL.match?(text)
33
+
34
+ integral(text.sub(/\.(?!\d)/, ".0").to_f, text)
35
+ end
36
+
37
+ def trim(text)
38
+ text.gsub(/\A[#{WHITESPACE}]+|[#{WHITESPACE}]+\z/o, "")
39
+ end
40
+
41
+ def radix_number(digits, base)
42
+ Integer(digits, base, exception: false) || Float::NAN
43
+ end
44
+
45
+ def integral(parsed, text)
46
+ return parsed unless parsed.finite? && parsed == parsed.floor
47
+ return Integer(text.delete_prefix("+"), 10) if text.match?(/\A[+-]?\d+\z/)
48
+
49
+ parsed.to_i
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Env
5
+ module Credentials
6
+ module_function
7
+
8
+ def token
9
+ from_env = Files.variable(Settings::ENV_VARS[:TOKEN])
10
+ return from_env if Files.text?(from_env)
11
+
12
+ directory = Files.config_dir
13
+ stored = directory && Files.json(File.join(directory, Settings::CONFIG_FILE))
14
+ candidate = stored && stored["token"]
15
+ Files.text?(candidate) ? candidate : nil
16
+ end
17
+
18
+ def machine_token
19
+ resolved = token
20
+ raise LoadError, Messages::TOKEN_MISSING if resolved.nil?
21
+ raise LoadError, Messages::TOKEN_NOT_MACHINE unless resolved.start_with?(Settings::MACHINE_TOKEN_PREFIX)
22
+
23
+ resolved
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Env
5
+ module Crypto
6
+ def encrypt(plaintext, passphrase, workspace_unique_id)
7
+ Envless.encrypt_value(plaintext, passphrase, workspace_unique_id)
8
+ end
9
+
10
+ def decrypt(ciphertext, passphrase, workspace_unique_id)
11
+ Envless.decrypt_value(ciphertext, passphrase, workspace_unique_id)
12
+ end
13
+
14
+ def encrypt_with_key(key, plaintext)
15
+ Envless.encrypt_with_key(key, plaintext)
16
+ end
17
+
18
+ def decrypt_with_key(key, ciphertext)
19
+ Envless.decrypt_with_key(key, ciphertext)
20
+ end
21
+
22
+ def workspace_key(passphrase, workspace_unique_id)
23
+ Envless.derive_workspace_key(passphrase, workspace_unique_id)
24
+ end
25
+
26
+ def import_workspace_key(material)
27
+ Envless.import_workspace_key(material)
28
+ end
29
+
30
+ def passphrase_strength(passphrase)
31
+ Envless.passphrase_strength(passphrase)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Env
5
+ module Decryptor
6
+ Outcome = Data.define(:entries, :failed)
7
+ FAILURES = [Envless::Error, ArgumentError].freeze
8
+
9
+ module_function
10
+
11
+ def call(variables, keyring)
12
+ # @type var entries: Array[[String, String]]
13
+ entries = []
14
+ variables.each do |variable|
15
+ name = variable[:name]
16
+ value = variable[:value]
17
+ next unless value.is_a?(String)
18
+ next entries << [name, ""] if value.empty?
19
+
20
+ plaintext = attempt(keyring, value)
21
+ return Outcome.new(entries: nil, failed: name) if plaintext.nil?
22
+
23
+ entries << [name, plaintext]
24
+ end
25
+ Outcome.new(entries:, failed: nil)
26
+ end
27
+
28
+ def attempt(keyring, value)
29
+ keyring.decrypt(value)
30
+ rescue *FAILURES
31
+ nil
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Env
5
+ class EnvironmentWriter
6
+ def initialize
7
+ @owned = []
8
+ end
9
+
10
+ def write(record)
11
+ previous = @owned
12
+ previous.each { |name| ENV.delete(name) unless record.key?(name) }
13
+ # @type var refused: Array[String]
14
+ refused = []
15
+ # @type var unwritable: Array[String]
16
+ unwritable = []
17
+ # @type var owned: Array[String]
18
+ owned = []
19
+ record.each do |name, value|
20
+ next refused << name if Settings::UNSAFE_NAMES.include?(name.downcase)
21
+ next if ENV.key?(name) && !previous.include?(name)
22
+ next unwritable << name unless writable?(name, value)
23
+
24
+ ENV[name] = value
25
+ owned << name
26
+ end
27
+ @owned = owned
28
+ report(refused, unwritable)
29
+ end
30
+
31
+ private
32
+
33
+ def writable?(name, value)
34
+ !name.empty? && !name.include?("=") && !name.include?("\0") && !value.include?("\0")
35
+ end
36
+
37
+ def report(refused, unwritable)
38
+ warn format(Messages::UNSAFE_REFUSED, names: refused.join(", ")) unless refused.empty?
39
+ warn format(Messages::UNWRITABLE, names: unwritable.join(", ")) unless unwritable.empty?
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Env
5
+ class Error < Envless::Error
6
+ end
7
+
8
+ class LoadError < Error
9
+ end
10
+
11
+ class NotLoadedError < Error
12
+ end
13
+
14
+ class VariableError < Error
15
+ attr_reader :variable
16
+
17
+ def initialize(variable, template)
18
+ @variable = variable
19
+ super(format(template, name: variable))
20
+ end
21
+ end
22
+
23
+ class MissingVariableError < VariableError
24
+ def initialize(variable)
25
+ super(variable, Messages::MISSING_VARIABLE)
26
+ end
27
+ end
28
+
29
+ class ServerOnlyError < VariableError
30
+ def initialize(variable)
31
+ super(variable, Messages::SERVER_ONLY)
32
+ end
33
+ end
34
+
35
+ class ReadOnlyError < Error
36
+ def initialize(message = Messages::READ_ONLY)
37
+ super
38
+ end
39
+ end
40
+
41
+ class SerialisationError < Error
42
+ def initialize(message = Messages::NOT_SERIALISABLE)
43
+ super
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Env
5
+ module Files
6
+ module_function
7
+
8
+ def json(path)
9
+ return nil unless File.file?(path)
10
+
11
+ parsed = JSON.parse(File.read(path, encoding: Encoding::UTF_8))
12
+ parsed.is_a?(Hash) ? parsed : nil
13
+ rescue JSON::ParserError, SystemCallError, IOError, EncodingError
14
+ nil
15
+ end
16
+
17
+ def home
18
+ Dir.home
19
+ rescue ArgumentError
20
+ nil
21
+ end
22
+
23
+ def config_dir
24
+ root = home
25
+ root && File.join(root, Settings::CONFIG_DIR)
26
+ end
27
+
28
+ def text?(value)
29
+ value.is_a?(String) && !value.empty?
30
+ end
31
+
32
+ def variable(name)
33
+ value = ENV.fetch(name, nil)
34
+ value && utf8(value)
35
+ end
36
+
37
+ def utf8(text)
38
+ return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
39
+
40
+ text.dup.force_encoding(Encoding::UTF_8).scrub
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Env
5
+ module Injected
6
+ TYPES = Settings::VAR_TYPES.values.freeze
7
+ VISIBILITIES = Settings::VISIBILITY.values.freeze
8
+
9
+ module_function
10
+
11
+ def seed
12
+ raw = Files.variable(Settings::INJECTED[:NAME])
13
+ return nil if raw.nil? || raw.empty?
14
+
15
+ manifest = parse(raw)
16
+ return nil unless manifest.is_a?(Hash)
17
+
18
+ # @type var record: Hash[String, String]
19
+ record = {}
20
+ # @type var meta: Hash[String, meta]
21
+ meta = {}
22
+ manifest.each do |name, entry|
23
+ next unless name.is_a?(String)
24
+
25
+ value = Files.variable(name)
26
+ next if value.nil? || Settings::UNSAFE_NAMES.include?(name.downcase)
27
+
28
+ record[name] = value
29
+ meta[name] = meta_of(entry)
30
+ end
31
+ record.empty? ? nil : [record, meta]
32
+ end
33
+
34
+ def parse(raw)
35
+ JSON.parse(raw)
36
+ rescue JSON::ParserError, EncodingError
37
+ nil
38
+ end
39
+
40
+ def meta_of(entry)
41
+ return {type: Settings::DEFAULT_TYPE, visibility: Settings::DEFAULT_VISIBILITY} unless entry.is_a?(Hash)
42
+
43
+ type = entry[Settings::INJECTED[:TYPE_KEY]]
44
+ visibility = entry[Settings::INJECTED[:VISIBILITY_KEY]]
45
+ {type: TYPES.include?(type) ? type : Settings::DEFAULT_TYPE,
46
+ visibility: VISIBILITIES.include?(visibility) ? visibility : Settings::DEFAULT_VISIBILITY}
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Envless
4
+ module Env
5
+ module Isolated
6
+ module_function
7
+
8
+ def call(token:, project:, environment:, product: nil, passphrase: nil, key: nil, workspace_id: nil, endpoint: nil,
9
+ adapter: nil)
10
+ raise LoadError, Messages::ISOLATED_NO_TOKEN unless Files.text?(token)
11
+ raise LoadError, Messages::ISOLATED_NO_TARGET unless Files.text?(project) && Files.text?(environment)
12
+ raise LoadError, Messages::ISOLATED_NO_KEY_MATERIAL unless Files.text?(passphrase) || Files.text?(key)
13
+
14
+ api = Api.new(token, endpoint:, adapter:)
15
+ begin
16
+ decrypted(api, keyring_of(api, passphrase, key, workspace_id), product, project, environment)
17
+ ensure
18
+ api.close
19
+ end
20
+ rescue Envless::Error, ArgumentError => error
21
+ raise error if error.is_a?(LoadError)
22
+
23
+ raise LoadError, error.message
24
+ end
25
+
26
+ def decrypted(api, keyring, product, project, environment)
27
+ target = Target.new(workspace: nil, product: Files.text?(product) ? product : nil, project:, environment:)
28
+ outcome = Decryptor.call(api.variables(target), keyring)
29
+ entries = outcome.entries
30
+ raise LoadError, format(Messages::ISOLATED_DECRYPT_FAILED, name: outcome.failed) if entries.nil?
31
+
32
+ entries.to_h
33
+ end
34
+
35
+ def keyring_of(api, passphrase, key, workspace_id)
36
+ return Keyring.imported(key) if key && !key.empty?
37
+
38
+ workspace = (workspace_id && !workspace_id.empty?) ? workspace_id : api.workspace_id
39
+ raise LoadError, Messages::ISOLATED_NO_WORKSPACE if workspace.nil? || workspace.empty?
40
+
41
+ Keyring.derived(passphrase.to_s, workspace)
42
+ end
43
+ end
44
+ end
45
+ end