mirror of
https://github.com/Homebrew/brew.git
synced 2026-10-28 09:34:42 +08:00
Preserve cask backups across lifecycle phases
- Share a private `0700` directory for one cask operation so sandbox cleanup cannot delete data awaiting restoration. - Fail if a recorded backup disappears before guarded restoration. - Retain the directory on failure and report its path for recovery. - Keep command scratch space isolated and clean up after success. Fixes #24293
This commit is contained in:
1 parent
38495687df
commit
9eaf55d627
8 files changed
+238
-1
No files matched your search
@@ -65,6 +65,7 @@ module Cask
|
||||
"staged_path" => cask.staged_path.to_s,
|
||||
"caskroom_path" => cask.caskroom_path.to_s,
|
||||
"home" => Dir.home,
|
||||
"temp" => cask.temp.to_s,
|
||||
"config" => cask.config.to_json,
|
||||
},
|
||||
"phase" => phase.to_s,
|
||||
|
||||
@@ -12,6 +12,7 @@ require "utils/output"
|
||||
require "utils/path"
|
||||
require "api_hashable"
|
||||
require "trust"
|
||||
require "tmpdir"
|
||||
|
||||
module Cask
|
||||
# An instance of a cask.
|
||||
@@ -49,6 +50,31 @@ module Cask
|
||||
sig { returns(T.nilable(Pathname)) }
|
||||
attr_accessor :download
|
||||
|
||||
sig { params(temp: T.nilable(Pathname)).void }
|
||||
attr_writer :temp
|
||||
|
||||
# Private temporary storage for the current cask lifecycle operation.
|
||||
# @api internal
|
||||
sig { returns(Pathname) }
|
||||
def temp
|
||||
@temp ||= Pathname(Dir.mktmpdir("cask-#{token}-", HOMEBREW_TEMP))
|
||||
end
|
||||
|
||||
# Retain files on failure so an incomplete restoration cannot lose data.
|
||||
# @api internal
|
||||
sig { params(retain: T::Boolean).void }
|
||||
def cleanup_temp(retain: false)
|
||||
temporary = @temp
|
||||
@temp = nil
|
||||
return unless temporary&.exist?
|
||||
|
||||
if retain
|
||||
::Utils::Output.opoo "Temporary files retained at:\n#{temporary}"
|
||||
else
|
||||
FileUtils.remove_entry_secure(temporary)
|
||||
end
|
||||
end
|
||||
|
||||
sig { returns(T::Boolean) }
|
||||
attr_accessor :allow_reassignment
|
||||
|
||||
@@ -111,6 +137,7 @@ module Cask
|
||||
@api_source = api_source
|
||||
@language_evaluator = T.let(nil, T.nilable(T.proc.params(languages: T::Array[String]).returns(T.nilable(String))))
|
||||
@loader = loader
|
||||
@temp = T.let(nil, T.nilable(Pathname))
|
||||
# Sorbet has trouble with bound procs assigned to instance variables:
|
||||
# https://github.com/sorbet/sorbet/issues/6843
|
||||
@block = T.let(block, T.untyped)
|
||||
|
||||
@@ -175,6 +175,7 @@ module Cask
|
||||
|
||||
sig { void }
|
||||
def install
|
||||
completed = false
|
||||
raise CaskError, "Download failed for #{@cask}." if download_failed?
|
||||
|
||||
start_time = Time.now
|
||||
@@ -214,6 +215,7 @@ on_request: true)
|
||||
puts summary
|
||||
end_time = Time.now
|
||||
Homebrew.messages.package_installed(@cask.token, end_time - start_time)
|
||||
completed = true
|
||||
rescue => e
|
||||
begin
|
||||
restore_backup
|
||||
@@ -221,6 +223,8 @@ on_request: true)
|
||||
purge_staged_download
|
||||
end
|
||||
raise e
|
||||
ensure
|
||||
@cask.cleanup_temp(retain: !completed)
|
||||
end
|
||||
|
||||
sig { void }
|
||||
@@ -625,6 +629,7 @@ on_request: true)
|
||||
|
||||
sig { params(successor: T.nilable(Cask)).void }
|
||||
def uninstall(successor: nil)
|
||||
completed = false
|
||||
load_installed_caskfile!
|
||||
oh1 "Uninstalling Cask #{Formatter.identifier(@cask)}"
|
||||
if !reinstall? && !upgrade? && @installed_uninstall_artifacts_missing && artifacts.empty?
|
||||
@@ -641,6 +646,9 @@ on_request: true)
|
||||
end
|
||||
purge_versioned_files
|
||||
purge_caskroom_path if force?
|
||||
completed = true
|
||||
ensure
|
||||
@cask.cleanup_temp(retain: !completed) if !reinstall? && !upgrade?
|
||||
end
|
||||
|
||||
sig { void }
|
||||
@@ -768,6 +776,7 @@ on_request: true)
|
||||
|
||||
sig { void }
|
||||
def zap
|
||||
completed = false
|
||||
load_installed_caskfile!
|
||||
oh1 "Zapping Cask #{Formatter.identifier(@cask)}"
|
||||
zap_stanzas = @cask.artifacts.grep(Artifact::Zap)
|
||||
@@ -775,6 +784,9 @@ on_request: true)
|
||||
uninstall_artifacts(clear: true, zap_stanzas:)
|
||||
ohai "Removing all staged versions of Cask '#{@cask}'"
|
||||
purge_caskroom_path
|
||||
completed = true
|
||||
ensure
|
||||
@cask.cleanup_temp(retain: !completed) if !reinstall? && !upgrade?
|
||||
end
|
||||
|
||||
sig { returns(T.nilable(Pathname)) }
|
||||
@@ -993,6 +1005,7 @@ on_request: true)
|
||||
# load the same cask file that was used for installation, if possible
|
||||
sig { void }
|
||||
def load_installed_caskfile!
|
||||
previous_cask = @cask
|
||||
Migrator.migrate_if_needed(@cask)
|
||||
|
||||
installed_caskfile = @cask.installed_caskfile
|
||||
@@ -1050,6 +1063,11 @@ on_request: true)
|
||||
recovered_cask = CaskLoader.recover_from_installed_caskfile(installed_caskfile, tab:, fallback_cask: @cask)
|
||||
# otherwise we default to the current cask
|
||||
@cask = recovered_cask if recovered_cask
|
||||
ensure
|
||||
if previous_cask
|
||||
@cask.temp = previous_cask.temp
|
||||
previous_cask.temp = nil if !reinstall? && !upgrade? && !@cask.equal?(previous_cask)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -388,6 +388,7 @@ module Cask
|
||||
binaries:, force:, require_sha:, quit:, skip_cask_deps:, verbose:, download_queue:,
|
||||
new_cask_installer: nil
|
||||
)
|
||||
completed = false
|
||||
start_time = Time.now
|
||||
odebug "Started upgrade process for Cask #{old_cask}"
|
||||
old_config = old_cask.config
|
||||
@@ -423,6 +424,7 @@ module Cask
|
||||
old_signing_identities = T.let({}, T::Hash[String, T.nilable(Quarantine::SigningIdentity)])
|
||||
old_user_approved = T.let({}, T::Hash[String, T::Boolean])
|
||||
old_unquarantined = T.let({}, T::Hash[String, T::Boolean])
|
||||
old_cask.temp = new_cask.temp
|
||||
|
||||
begin
|
||||
oh1 "Upgrading #{Formatter.identifier(old_cask)}"
|
||||
@@ -520,6 +522,10 @@ module Cask
|
||||
|
||||
end_time = Time.now
|
||||
Homebrew.messages.package_installed(new_cask.token, end_time - start_time)
|
||||
completed = true
|
||||
ensure
|
||||
new_cask.cleanup_temp(retain: !completed)
|
||||
old_cask.temp = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -39,6 +39,9 @@ module Cask
|
||||
sig { returns(Pathname) }
|
||||
attr_reader :home
|
||||
|
||||
sig { returns(Pathname) }
|
||||
attr_reader :temp
|
||||
|
||||
sig { returns(Config) }
|
||||
attr_reader :config
|
||||
|
||||
@@ -51,6 +54,7 @@ module Cask
|
||||
@staged_path = T.let(Pathname(context.fetch("staged_path")), Pathname)
|
||||
@caskroom_path = T.let(Pathname(context.fetch("caskroom_path")), Pathname)
|
||||
@home = T.let(Pathname(context.fetch("home")), Pathname)
|
||||
@temp = T.let(Pathname(context.fetch("temp")), Pathname)
|
||||
@config = T.let(Config.from_json(context.fetch("config"), ignore_invalid_keys: true), Config)
|
||||
end
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
require "simulate_system"
|
||||
require "system_command"
|
||||
require "utils/output"
|
||||
require "digest"
|
||||
|
||||
module Homebrew
|
||||
# Declarative install steps that can be serialised through the JSON APIs.
|
||||
@@ -1024,6 +1025,11 @@ module Homebrew
|
||||
else
|
||||
FileUtils.mv source, target, force: step["force"] == true
|
||||
end
|
||||
if (marker = temporary_backup_marker(destination))
|
||||
marker.dirname.mkpath
|
||||
FileUtils.touch marker
|
||||
end
|
||||
FileUtils.rm_f marker if (marker = temporary_backup_marker(source))
|
||||
when "move_children", "move_contents"
|
||||
source = resolve_path(step_path(step, "source"))
|
||||
target = resolve_path(step_path(step, "target"))
|
||||
@@ -1215,10 +1221,29 @@ module Homebrew
|
||||
|
||||
sig { params(step: Step).returns(T::Boolean) }
|
||||
def step_guards_match?(step)
|
||||
if step["type"] == "move" && context_value(:temp)
|
||||
source = resolve_path(step_path(step, "source"))
|
||||
if temporary_backup_marker(source)&.exist? && !source.exist? && !source.symlink?
|
||||
raise "Expected cask backup is missing:\n#{source}"
|
||||
end
|
||||
end
|
||||
|
||||
guards = T.cast(step["guards"], T.nilable(PathSpecs))
|
||||
guards.nil? || guards.all? { |guard| guard_matches?(guard) }
|
||||
end
|
||||
|
||||
sig { params(path: Pathname).returns(T.nilable(Pathname)) }
|
||||
def temporary_backup_marker(path)
|
||||
temporary = context_value(:temp)
|
||||
return unless temporary
|
||||
|
||||
directory = Pathname(temporary.to_s).expand_path
|
||||
path = path.expand_path
|
||||
return unless path.ascend.include?(directory)
|
||||
|
||||
directory/".homebrew-backups"/Digest::SHA256.hexdigest(path.to_s)
|
||||
end
|
||||
|
||||
sig { params(guard: PathSpec).returns(T::Boolean) }
|
||||
def guard_matches?(guard)
|
||||
return @guard_results.fetch(guard) if @guard_results.key?(guard)
|
||||
@@ -1586,7 +1611,7 @@ module Homebrew
|
||||
when "home"
|
||||
context_value(:home) ? context_path(base) : Pathname(Dir.home)
|
||||
when "temp"
|
||||
HOMEBREW_TEMP
|
||||
context_value(:temp) ? context_path(base) : HOMEBREW_TEMP
|
||||
when "homebrew_prefix"
|
||||
HOMEBREW_PREFIX
|
||||
when "formula_pkgetc"
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "cask/upgrade"
|
||||
|
||||
RSpec.describe Cask::Installer, :cask do
|
||||
sig { params(version: String).returns(Cask::Cask) }
|
||||
def preserving_cask(version)
|
||||
Cask::Cask.new("with-preserved-environments", source: "{}") do
|
||||
version version
|
||||
sha256 :no_check
|
||||
url "file://#{TEST_FIXTURE_DIR}/cask/container.zip"
|
||||
|
||||
uninstall_preflight_steps do
|
||||
if_path_exists "base/envs", base: :caskroom_path do
|
||||
move "base/envs", "{{temp}}/{{token}}-envs", source_base: :caskroom_path
|
||||
end
|
||||
end
|
||||
|
||||
preflight_steps do
|
||||
remove "base", base: :caskroom_path, recursive: true
|
||||
mkdir_p "base/envs", base: :caskroom_path
|
||||
end
|
||||
|
||||
postflight_steps do
|
||||
if_path_exists "{{token}}-envs", base: :temp do
|
||||
remove "base/envs", base: :caskroom_path, recursive: true
|
||||
move "{{token}}-envs", "base/envs", source_base: :temp, target_base: :caskroom_path
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Keep each memoised helper with its type signature.
|
||||
# rubocop:disable RSpec/ScatteredLet
|
||||
sig { returns(Cask::Cask) }
|
||||
let(:old_cask) { preserving_cask("1") }
|
||||
|
||||
sig { returns(Cask::Cask) }
|
||||
let(:new_cask) { preserving_cask("2") }
|
||||
|
||||
sig { returns(Pathname) }
|
||||
let(:marker) { old_cask.caskroom_path/"base/envs/demo/marker" }
|
||||
|
||||
sig { returns(T::Array[Pathname]) }
|
||||
let(:temporary_paths) { [] }
|
||||
|
||||
sig { returns(T::Array[Integer]) }
|
||||
let(:temporary_modes) { [] }
|
||||
# rubocop:enable RSpec/ScatteredLet
|
||||
|
||||
sig { params(operation: Symbol).void }
|
||||
def run_operation(operation)
|
||||
if operation == :upgrade
|
||||
Cask::Upgrade.upgrade_cask(old_cask, new_cask, binaries: true, force: false, require_sha: false,
|
||||
quit: false, skip_cask_deps: true, verbose: false,
|
||||
download_queue: Homebrew::DownloadQueue.default)
|
||||
else
|
||||
described_class.new(new_cask, reinstall: true, zap: operation == :reinstall_with_zap).install
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
stub_const("HOMEBREW_TEMP", mktmpdir)
|
||||
old_cask.staged_path.mkpath
|
||||
described_class.new(old_cask).save_caskfile
|
||||
Cask::Tab.create(old_cask).write
|
||||
marker.dirname.mkpath
|
||||
marker.write "preserve me\n"
|
||||
|
||||
allow(described_class).to receive(:new).and_wrap_original do |constructor, *args, **options|
|
||||
constructor.call(*args, **options).tap do |installer|
|
||||
allow(installer).to receive_messages(prelude: nil, fetch: nil)
|
||||
allow(installer).to receive(:stage) do
|
||||
installer.cask.staged_path.mkpath
|
||||
installer.save_caskfile
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
sandbox = instance_double(Sandbox).as_null_object
|
||||
allow(Sandbox).to receive_messages(available?: true, avoid_nested_sandboxing?: false, new: sandbox)
|
||||
allow(Sandbox).to receive(:with_preserved_brew_file).and_yield
|
||||
allow(sandbox).to receive(:run) do |*args, **|
|
||||
temporary = Pathname(JSON.parse(File.binread(args.fetch(-2))).fetch("context").fetch("temp"))
|
||||
temporary_paths << temporary
|
||||
temporary_modes << (temporary.stat.mode & 0777)
|
||||
Dir.mktmpdir("sandbox-", HOMEBREW_TEMP) do |tmpdir|
|
||||
File.open(HOMEBREW_LIBRARY_PATH/"sandbox.rb") do |inheritance|
|
||||
Utils.safe_fork do
|
||||
exec({ "HOMEBREW_TEMP" => tmpdir }, *args.map(&:to_s), Sandbox::INHERITANCE_FD => inheritance)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test_each([:upgrade, :reinstall, :reinstall_with_zap]) do |operation|
|
||||
it "shares a private temporary directory until restoration, then removes it" do
|
||||
run_operation(operation)
|
||||
|
||||
expect([
|
||||
marker.read,
|
||||
temporary_paths.uniq.length,
|
||||
temporary_paths.map(&:parent).uniq,
|
||||
temporary_modes.uniq,
|
||||
temporary_paths.any?(&:exist?),
|
||||
]).to eq(["preserve me\n", 1, [HOMEBREW_TEMP], [0700], false])
|
||||
end
|
||||
|
||||
it "retains backups when installation and restoration fail" do
|
||||
allow_any_instance_of(Cask::Artifact::PostflightSteps).to receive(:install_phase).and_raise("restore failed")
|
||||
|
||||
expect { run_operation(operation) }.to raise_error("restore failed")
|
||||
.and output(/Temporary files retained at:/).to_stderr
|
||||
expect((temporary_paths.fetch(0)/"#{old_cask.token}-envs/demo/marker").read).to eq("preserve me\n")
|
||||
end
|
||||
|
||||
it "fails and retains temporary storage when an expected backup disappears" do
|
||||
allow_any_instance_of(Cask::Artifact::PostflightSteps).to receive(:install_phase)
|
||||
.and_wrap_original do |method, **args|
|
||||
FileUtils.remove_entry_secure(new_cask.temp/"#{old_cask.token}-envs") if
|
||||
(new_cask.temp/"#{old_cask.token}-envs").exist?
|
||||
method.call(**args)
|
||||
end
|
||||
|
||||
expect { run_operation(operation) }.to raise_error(/Expected cask backup is missing:/)
|
||||
.and output(/Temporary files retained at:/).to_stderr
|
||||
expect(temporary_paths.fetch(0)).to be_a_directory
|
||||
end
|
||||
|
||||
it "does not require a backup when there was no data to preserve" do
|
||||
FileUtils.remove_entry_secure(marker.dirname.parent)
|
||||
|
||||
expect { run_operation(operation) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
it "uses a fresh private directory for separate uninstall and install operations" do
|
||||
described_class.new(old_cask).uninstall
|
||||
described_class.new(old_cask).install
|
||||
|
||||
expect([
|
||||
temporary_paths.uniq.length,
|
||||
temporary_modes.uniq,
|
||||
temporary_paths.any?(&:exist?),
|
||||
marker.exist?,
|
||||
]).to eq([2, [0700], false, false])
|
||||
end
|
||||
end
|
||||
@@ -652,6 +652,12 @@ The runtime steps DSL retains compatibility helpers for `token`, `name`, `arch`,
|
||||
|
||||
Content, replacements, command arguments and command environments may use fixed install-time tokens. These include `{{HOMEBREW_BREW_FILE}}`, `{{HOMEBREW_CELLAR}}`, `{{HOMEBREW_PREFIX}}`, `{{token}}`, `{{name}}`, `{{arch}}`, `{{user}}`, `{{staged_path}}`, `{{appdir}}`, `{{caskroom_path}}`, `{{temp}}`, `{{version}}`, `{{version.major}}` and `{{version.major_minor}}`. `{{name}}` is retained for compatibility; prefer `{{token}}`. `{{arch}}` expands to the value the cask's `arch` stanza selected for the current architecture, and to an empty string when the cask has no matching value. Any other `{{...}}` is left verbatim. For example: `write_file "settings.conf", "application = {{appdir}}/Example.app"`.
|
||||
|
||||
`{{temp}}` and `base: :temp` use a private, uniquely created directory with permissions `0700`, shared across the phases of one cask upgrade or reinstall so backups can be restored by a later phase.
|
||||
Successful `move` steps into this directory record expected backups; a later `move` from a missing backup fails even when guarded by `if_path_exists`.
|
||||
Homebrew removes this directory after a successful operation; on failure, it retains the directory and reports its location for recovery.
|
||||
Separate uninstall and install commands do not share this directory.
|
||||
Commands' temporary environment variables still point to the sandbox's disposable directory for each phase.
|
||||
|
||||
{% endraw %}
|
||||
|
||||
### Stanza: `generate_completions_from_executable`
|
||||
|
||||
Reference in new issue
Block a user