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:
Mike McQuaid committed 2026-10-18 15:27:27 +01:00
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,
+27
View File
@@ -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)
+18
View File
@@ -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
+6
View File
@@ -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
+4
View File
@@ -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
+26 -1
View File
@@ -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
+6
View File
@@ -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`