Class: Lich::DependencyRecovery

Inherits:
Object
  • Object
show all
Defined in:
documented/dependency_recovery.rb

Overview

Restores approved runtime gems from the Ruby4Lich5 manifest when an installed runtime is incomplete or a native extension cannot load. The manifest is deliberately an allow-list: a gem name is never turned into a URL, shell command, or RubyGems request by this class.

A manifest is a release asset controlled by the same human-reviewed promotion as the gem bundle. Its SHA-256 values protect against accidental corruption and mismatched assets; they are not a substitute for signing, because the manifest and assets currently share a publisher.

Defined Under Namespace

Classes: Error, Plan, Result

Constant Summary collapse

DEFAULT_MANIFEST_URL =

Default HTTPS URL to the Ruby4Lich5 gem bundle manifest.

See Also:

'https://github.com/Lich5/Ruby4Lich5/releases/download/' \
'R4L5-gem-bundle-x64-mingw-ucrt/R4L5-gem-manifest.json'
LOCK_FILENAME =
'.lich-dependency-recovery.lock'
HTTP_OPEN_TIMEOUT_SECONDS =
10
HTTP_READ_TIMEOUT_SECONDS =
60
SHA256_PATTERN =

Regexp matching a SHA-256 digest in the format required by manifest entries.

Examples:

Match a valid manifest digest

"sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" =~ SHA256_PATTERN #=> 0

Non-match: uppercase hex

"sha256:ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789" =~ SHA256_PATTERN #=> nil
/\Asha256:[0-9a-f]{64}\z/
SAFE_FILENAME =

Regexp matching a safe archive member filename.

Allows any non-empty sequence of characters except backslash and forward slash, but rejects pure dot sequences (.", ..", etc.) to prevent directory traversal during ZIP extraction.

Examples:

Valid filenames

"gem-1.2.3.gem" =~ SAFE_FILENAME #=> 0
"lib/extconf.rb" =~ SAFE_FILENAME #=> nil (contains /)

Invalid filenames

".." =~ SAFE_FILENAME #=> nil
"." =~ SAFE_FILENAME #=> nil

See Also:

  • #require_filename!
/\A(?!\.+\z)[^\\\/]+\z/
SAFE_NAME =

Regexp matching a safe gem or unit name in the manifest.

Restricts names to alphanumeric characters, underscore, period, and hyphen. This prevents manifest names from being misinterpreted as shell commands or path components.

Examples:

Valid names

"gtk3" =~ SAFE_NAME #=> 0
"nokogiri-1.15.0" =~ SAFE_NAME #=> 0

Invalid names

"../../../etc/passwd" =~ SAFE_NAME #=> nil
"gem; rm -rf /" =~ SAFE_NAME #=> nil

See Also:

  • #validate_unit!
  • #validate_package!
/\A[a-zA-Z0-9_.-]+\z/

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(manifest_url: ENV.fetch('LICH_GEM_MANIFEST_URL', DEFAULT_MANIFEST_URL), gem_home: Gem.dir, temp_dir: (TEMP_DIR) ? TEMP_DIR : Dir.tmpdir), http_get: nil, install_gem: nil, extract_zip: nil, powershell_runner: nil, helper_launcher: nil) ⇒ DependencyRecovery

Returns a new instance of DependencyRecovery.

Parameters:

  • manifest_url (String) (defaults to: ENV.fetch('LICH_GEM_MANIFEST_URL', DEFAULT_MANIFEST_URL))

    HTTPS manifest location

  • gem_home (String) (defaults to: Gem.dir)

    runtime-owned installation directory

  • temp_dir (String) (defaults to: (TEMP_DIR) ? TEMP_DIR : Dir.tmpdir))

    Lich-owned directory for transient recovery files

  • http_get (#call, nil) (defaults to: nil)

    test seam returning binary response content

  • install_gem (#call, nil) (defaults to: nil)

    test seam for local gem installation

  • extract_zip (#call, nil) (defaults to: nil)

    test seam for zip extraction

  • powershell_runner (#call, nil) (defaults to: nil)

    test seam for the Windows extractor



100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'documented/dependency_recovery.rb', line 100

def initialize(manifest_url: ENV.fetch('LICH_GEM_MANIFEST_URL', DEFAULT_MANIFEST_URL),
               gem_home: Gem.dir, temp_dir: (defined?(TEMP_DIR) ? TEMP_DIR : Dir.tmpdir),
               http_get: nil, install_gem: nil, extract_zip: nil, powershell_runner: nil,
               helper_launcher: nil)
  @manifest_url = manifest_url
  @gem_home = gem_home
  @temp_dir = temp_dir
  @http_get = http_get
  @install_gem = install_gem || method(:install_gem_file)
  @extract_zip = extract_zip || method(:extract_zip_file)
  @powershell_runner = powershell_runner || Open3.method(:capture3)
  @helper_launcher = helper_launcher || method(:launch_windows_helper)
end

Class Method Details

.log_helper_failure(payload_path, error) ⇒ void

This method returns an undefined value.

Parameters:

  • payload_path (String)
  • error (Exception)


191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'documented/dependency_recovery.rb', line 191

def self.log_helper_failure(payload_path, error)
  payload = JSON.parse(File.read(payload_path)) rescue {}
  log_path = File.join(payload.fetch('temp_dir', Dir.tmpdir), 'lich5-missing-gems.log')
  File.open(log_path, 'a') do |file|
    file.puts "[#{Time.now}] Lich5 native gem replacement failure"
    file.puts "  #{error.class}: #{error.message}"
    file.puts
  end
  return unless Gem.win_platform?

  require 'win32ole'
  WIN32OLE.new('WScript.Shell').Popup(
    "Lich could not update the GTK runtime. See #{log_path} for details.",
    0, 'Lich5: Ruby Gem Recovery', 16
  )
rescue StandardError
  nil
end

.run_windows_replacement(payload_path) ⇒ Boolean

Runs in a detached Ruby process after the original Lich process exits. The payload contains only already-hash-verified package paths and the original Lich invocation; artifacts are re-verified before replacement.

Parameters:

  • payload_path (String)

    JSON transaction description

Returns:

  • (Boolean)

    whether the replacement and restart succeeded



178
179
180
181
182
183
184
185
186
# File 'documented/dependency_recovery.rb', line 178

def self.run_windows_replacement(payload_path)
  payload = JSON.parse(File.read(payload_path))
  recovery = new(gem_home: payload.fetch('gem_home'), temp_dir: payload.fetch('temp_dir'))
  recovery.send(:run_windows_replacement!, payload)
  true
rescue StandardError => e
  log_helper_failure(payload_path, e)
  false
end

Instance Method Details

#recover(gem_names, force: false, plan: nil) ⇒ Result

Downloads, verifies, and installs a previously approved manifest plan. Installation always targets Gem.dir (or the injected runtime directory), never a user-controlled GEM_HOME.

Parameters:

  • gem_names (Array<String>)

    approved gem names to restore

  • force (Boolean) (defaults to: false)

    reinstall even when the manifest version is present

  • plan (Plan, nil) (defaults to: nil)

    plan obtained before user consent

Returns:



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'documented/dependency_recovery.rb', line 138

def recover(gem_names, force: false, plan: nil)
  planned = plan || recovery_plan(gem_names)
  return Result.new(installed_gems: [], error: planned.error) unless planned.success?

  result = with_recovery_workspace do |work_dir|
    artifact_cache = {}
    # No runtime lock or Gem.dir write occurs until every requested unit is
    # downloaded, extracted, and hash-verified. An unreachable release
    # endpoint therefore fails closed without changing the runtime.
    staged_units = planned.units.map { |unit| stage_unit(unit, work_dir, artifact_cache) }
    with_install_lock do
      replacement, direct = staged_units.partition { |staged| native_runtime_unit?(staged.fetch(:unit)) }
      installed = direct.flat_map { |staged| install_staged_unit(staged, force: force) }

      if replacement.empty?
        Result.new(installed_gems: installed)
      elsif replacement.length == 1
        schedule_runtime_replacement(replacement.first, work_dir)
        Result.new(installed_gems: installed, restart_required: true)
      else
        raise Error, 'multiple native runtime replacement units are not supported in one recovery'
      end
    end
  end
  refresh_rubygems! unless result.restart_required
  result
rescue Error, JSON::ParserError, OpenURI::HTTPError, SocketError, SystemCallError => e
  Result.new(installed_gems: [], error: e.message)
rescue StandardError => e
  # Recovery runs during boot. Keep an unexpected implementation failure
  # from turning into an unhelpful backtrace before GemCheck can report it.
  Result.new(installed_gems: [], error: "#{e.class}: #{e.message}")
end

#recovery_plan(gem_names) ⇒ Plan

Loads and validates the manifest units covering gem_names, without downloading gem artifacts. Callers use this to request consent first.

Parameters:

  • gem_names (Array<String>)

    approved gem names to restore

Returns:



119
120
121
122
123
124
125
126
127
128
# File 'documented/dependency_recovery.rb', line 119

def recovery_plan(gem_names)
  requested = Array(gem_names).map(&:to_s).reject(&:empty?).uniq
  return Plan.new(units: []) if requested.empty?

  Plan.new(units: units_for(load_manifest, requested))
rescue Error, JSON::ParserError, OpenURI::HTTPError, SocketError, SystemCallError => e
  Plan.new(units: [], error: e.message)
rescue StandardError => e
  Plan.new(units: [], error: "#{e.class}: #{e.message}")
end