diff --git a/Library/Homebrew/cask/artifact/install_steps.rb b/Library/Homebrew/cask/artifact/install_steps.rb index 344bf1618d..28e6aca89f 100644 --- a/Library/Homebrew/cask/artifact/install_steps.rb +++ b/Library/Homebrew/cask/artifact/install_steps.rb @@ -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, diff --git a/Library/Homebrew/cask/cask.rb b/Library/Homebrew/cask/cask.rb index e77a032640..00bf5420f3 100644 --- a/Library/Homebrew/cask/cask.rb +++ b/Library/Homebrew/cask/cask.rb @@ -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) diff --git a/Library/Homebrew/cask/installer.rb b/Library/Homebrew/cask/installer.rb index 708242188f..e25ecb7ef9 100644 --- a/Library/Homebrew/cask/installer.rb +++ b/Library/Homebrew/cask/installer.rb @@ -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 diff --git a/Library/Homebrew/cask/upgrade.rb b/Library/Homebrew/cask/upgrade.rb index d1d07878c4..7bb8d0035c 100644 --- a/Library/Homebrew/cask/upgrade.rb +++ b/Library/Homebrew/cask/upgrade.rb @@ -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 diff --git a/Library/Homebrew/cask_artifact.rb b/Library/Homebrew/cask_artifact.rb index f4ac052c94..a0357a7082 100644 --- a/Library/Homebrew/cask_artifact.rb +++ b/Library/Homebrew/cask_artifact.rb @@ -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 diff --git a/Library/Homebrew/install_steps.rb b/Library/Homebrew/install_steps.rb index 3bde369ea1..6b7daa09ad 100644 --- a/Library/Homebrew/install_steps.rb +++ b/Library/Homebrew/install_steps.rb @@ -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" diff --git a/Library/Homebrew/test/cask/lifecycle_temp_spec.rb b/Library/Homebrew/test/cask/lifecycle_temp_spec.rb new file mode 100644 index 0000000000..730e0020f7 --- /dev/null +++ b/Library/Homebrew/test/cask/lifecycle_temp_spec.rb @@ -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 diff --git a/docs/Cask-Cookbook.md b/docs/Cask-Cookbook.md index f81ace2688..6925e1f732 100644 --- a/docs/Cask-Cookbook.md +++ b/docs/Cask-Cookbook.md @@ -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`