2023-03-20 13:15:43 -07:00
|
|
|
# typed: true
|
2019-04-19 15:38:03 +09:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
2018-07-23 23:04:49 +02:00
|
|
|
require "tempfile"
|
|
|
|
|
|
|
|
module UnpackStrategy
|
2020-08-09 06:09:05 +02:00
|
|
|
# Strategy for unpacking disk images.
|
2018-07-23 23:04:49 +02:00
|
|
|
class Dmg
|
2020-10-20 12:03:48 +02:00
|
|
|
extend T::Sig
|
|
|
|
|
2018-07-23 23:04:49 +02:00
|
|
|
include UnpackStrategy
|
|
|
|
|
2020-08-09 06:09:05 +02:00
|
|
|
# Helper module for listing the contents of a volume mounted from a disk image.
|
2018-07-24 07:03:24 +02:00
|
|
|
module Bom
|
2023-03-20 13:15:43 -07:00
|
|
|
extend T::Sig
|
|
|
|
|
2019-04-19 21:46:20 +09:00
|
|
|
DMG_METADATA = Set.new(%w[
|
2021-02-11 13:24:19 +00:00
|
|
|
.background
|
|
|
|
.com.apple.timemachine.donotpresent
|
|
|
|
.com.apple.timemachine.supported
|
|
|
|
.DocumentRevisions-V100
|
|
|
|
.DS_Store
|
|
|
|
.fseventsd
|
|
|
|
.MobileBackups
|
|
|
|
.Spotlight-V100
|
|
|
|
.TemporaryItems
|
|
|
|
.Trashes
|
|
|
|
.VolumeIcon.icns
|
|
|
|
]).freeze
|
2018-07-24 07:03:24 +02:00
|
|
|
private_constant :DMG_METADATA
|
|
|
|
|
2023-03-13 18:08:53 +01:00
|
|
|
class Error < RuntimeError; end
|
|
|
|
|
|
|
|
class EmptyError < Error
|
|
|
|
def initialize(path)
|
|
|
|
super "BOM for path '#{path}' is empty."
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2023-03-20 13:15:43 -07:00
|
|
|
# Check if path is considered disk image metadata.
|
|
|
|
sig { params(pathname: Pathname).returns(T::Boolean) }
|
|
|
|
def self.dmg_metadata?(pathname)
|
|
|
|
DMG_METADATA.include?(pathname.cleanpath.ascend.to_a.last.to_s)
|
|
|
|
end
|
2018-07-24 07:03:24 +02:00
|
|
|
|
2023-03-20 13:15:43 -07:00
|
|
|
# Check if path is a symlink to a system directory (commonly to /Applications).
|
|
|
|
sig { params(pathname: Pathname).returns(T::Boolean) }
|
|
|
|
def self.system_dir_symlink?(pathname)
|
|
|
|
pathname.symlink? && MacOS.system_dir?(pathname.dirname.join(pathname.readlink))
|
|
|
|
end
|
2018-07-24 07:03:24 +02:00
|
|
|
|
2023-03-20 13:15:43 -07:00
|
|
|
sig { params(pathname: Pathname).returns(String) }
|
|
|
|
def self.bom(pathname)
|
|
|
|
tries = 0
|
|
|
|
result = loop do
|
|
|
|
# We need to use `find` here instead of Ruby in order to properly handle
|
|
|
|
# file names containing special characters, such as “e” + “´” vs. “é”.
|
|
|
|
r = system_command("find", args: [".", "-print0"], chdir: pathname, print_stderr: false)
|
|
|
|
tries += 1
|
2020-11-25 22:19:52 +01:00
|
|
|
|
2023-03-20 13:15:43 -07:00
|
|
|
# Spurious bug on CI, which in most cases can be worked around by retrying.
|
|
|
|
break r unless r.stderr.match?(/Interrupted system call/i)
|
2020-11-25 22:19:52 +01:00
|
|
|
|
2023-03-20 13:15:43 -07:00
|
|
|
raise "Command `#{r.command.shelljoin}` was interrupted." if tries >= 3
|
|
|
|
end
|
2020-11-25 22:19:52 +01:00
|
|
|
|
2023-03-20 13:15:43 -07:00
|
|
|
odebug "Command `#{result.command.shelljoin}` in '#{pathname}' took #{tries} tries." if tries > 1
|
2020-11-26 11:24:39 +01:00
|
|
|
|
2023-03-20 13:15:43 -07:00
|
|
|
bom_paths = result.stdout.split("\0")
|
2020-11-26 11:24:39 +01:00
|
|
|
|
2023-03-20 13:15:43 -07:00
|
|
|
raise EmptyError, pathname if bom_paths.empty?
|
2020-11-25 19:37:21 +01:00
|
|
|
|
2023-03-20 13:15:43 -07:00
|
|
|
bom_paths
|
|
|
|
.reject { |path| dmg_metadata?(Pathname(path)) }
|
|
|
|
.reject { |path| system_dir_symlink?(pathname/path) }
|
|
|
|
.join("\n")
|
2018-07-24 07:03:24 +02:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2020-08-09 06:09:05 +02:00
|
|
|
# Strategy for unpacking a volume mounted from a disk image.
|
2018-07-24 07:03:24 +02:00
|
|
|
class Mount
|
2020-10-20 12:03:48 +02:00
|
|
|
extend T::Sig
|
|
|
|
|
2018-07-24 07:03:24 +02:00
|
|
|
include UnpackStrategy
|
|
|
|
|
2018-07-24 18:43:20 +02:00
|
|
|
def eject(verbose: false)
|
2023-03-20 13:15:43 -07:00
|
|
|
tries = 3
|
|
|
|
begin
|
|
|
|
return unless path.exist?
|
|
|
|
|
|
|
|
if tries > 1
|
|
|
|
disk_info = system_command!(
|
|
|
|
"diskutil",
|
|
|
|
args: ["info", "-plist", path],
|
|
|
|
print_stderr: false,
|
|
|
|
verbose: verbose,
|
|
|
|
)
|
|
|
|
|
|
|
|
# For HFS, just use <mount-path>
|
|
|
|
# For APFS, find the <physical-store> corresponding to <mount-path>
|
|
|
|
eject_paths = disk_info.plist
|
2023-03-20 13:16:31 -07:00
|
|
|
.fetch("APFSPhysicalStores", [])
|
|
|
|
.map { |store| store["APFSPhysicalStore"] }
|
|
|
|
.compact
|
|
|
|
.presence || [path]
|
2023-03-20 13:15:43 -07:00
|
|
|
|
|
|
|
eject_paths.each do |eject_path|
|
|
|
|
system_command! "diskutil",
|
|
|
|
args: ["eject", eject_path],
|
|
|
|
print_stderr: false,
|
|
|
|
verbose: verbose
|
|
|
|
end
|
|
|
|
else
|
2021-03-28 04:04:07 -07:00
|
|
|
system_command! "diskutil",
|
2023-03-20 13:15:43 -07:00
|
|
|
args: ["unmount", "force", path],
|
2021-03-28 04:04:07 -07:00
|
|
|
print_stderr: false,
|
|
|
|
verbose: verbose
|
|
|
|
end
|
2023-03-20 13:15:43 -07:00
|
|
|
rescue ErrorDuringExecution => e
|
|
|
|
raise e if (tries -= 1).zero?
|
2018-09-17 02:45:00 +02:00
|
|
|
|
2023-03-20 13:15:43 -07:00
|
|
|
sleep 1
|
|
|
|
retry
|
|
|
|
end
|
2018-07-24 07:03:24 +02:00
|
|
|
end
|
|
|
|
|
|
|
|
private
|
|
|
|
|
2020-10-20 12:03:48 +02:00
|
|
|
sig { override.params(unpack_dir: Pathname, basename: Pathname, verbose: T::Boolean).returns(T.untyped) }
|
2018-07-24 07:03:24 +02:00
|
|
|
def extract_to_dir(unpack_dir, basename:, verbose:)
|
2023-03-20 13:15:43 -07:00
|
|
|
tries = 3
|
2023-03-13 18:08:53 +01:00
|
|
|
bom = begin
|
2023-03-20 13:15:43 -07:00
|
|
|
Bom.bom(path)
|
2023-03-13 18:08:53 +01:00
|
|
|
rescue Bom::EmptyError => e
|
2023-03-21 00:39:22 +01:00
|
|
|
raise e if (tries -= 1).zero?
|
2023-03-13 18:08:53 +01:00
|
|
|
|
|
|
|
sleep 1
|
|
|
|
retry
|
|
|
|
end
|
|
|
|
|
2018-07-24 07:03:24 +02:00
|
|
|
Tempfile.open(["", ".bom"]) do |bomfile|
|
|
|
|
bomfile.close
|
|
|
|
|
|
|
|
Tempfile.open(["", ".list"]) do |filelist|
|
2023-03-13 18:08:53 +01:00
|
|
|
filelist.puts(bom)
|
2018-07-24 07:03:24 +02:00
|
|
|
filelist.close
|
|
|
|
|
2018-07-24 18:43:20 +02:00
|
|
|
system_command! "mkbom",
|
2018-11-02 17:18:07 +00:00
|
|
|
args: ["-s", "-i", filelist.path, "--", bomfile.path],
|
2018-07-24 18:43:20 +02:00
|
|
|
verbose: verbose
|
2018-07-24 07:03:24 +02:00
|
|
|
end
|
|
|
|
|
2020-11-26 11:24:39 +01:00
|
|
|
system_command! "ditto",
|
|
|
|
args: ["--bom", bomfile.path, "--", path, unpack_dir],
|
|
|
|
verbose: verbose
|
2018-07-29 10:04:51 +02:00
|
|
|
|
2018-08-31 13:16:11 +00:00
|
|
|
FileUtils.chmod "u+w", Pathname.glob(unpack_dir/"**/*", File::FNM_DOTMATCH).reject(&:symlink?)
|
2018-07-24 07:03:24 +02:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
private_constant :Mount
|
|
|
|
|
2020-10-20 12:03:48 +02:00
|
|
|
sig { returns(T::Array[String]) }
|
2018-07-30 09:49:59 +02:00
|
|
|
def self.extensions
|
|
|
|
[".dmg"]
|
|
|
|
end
|
2018-07-29 10:04:51 +02:00
|
|
|
|
2018-07-30 09:49:59 +02:00
|
|
|
def self.can_extract?(path)
|
2019-10-14 10:44:52 +02:00
|
|
|
stdout, _, status = system_command("hdiutil", args: ["imageinfo", "-format", path], print_stderr: false)
|
2019-10-13 17:19:02 +02:00
|
|
|
status.success? && !stdout.empty?
|
2018-07-23 23:04:49 +02:00
|
|
|
end
|
|
|
|
|
2018-07-24 07:03:24 +02:00
|
|
|
private
|
|
|
|
|
2020-10-20 12:03:48 +02:00
|
|
|
sig { override.params(unpack_dir: Pathname, basename: Pathname, verbose: T::Boolean).returns(T.untyped) }
|
2018-07-23 23:04:49 +02:00
|
|
|
def extract_to_dir(unpack_dir, basename:, verbose:)
|
|
|
|
mount(verbose: verbose) do |mounts|
|
2019-04-08 12:47:15 -04:00
|
|
|
raise "No mounts found in '#{path}'; perhaps this is a bad disk image?" if mounts.empty?
|
2018-07-24 07:03:24 +02:00
|
|
|
|
|
|
|
mounts.each do |mount|
|
2018-07-24 18:43:20 +02:00
|
|
|
mount.extract(to: unpack_dir, verbose: verbose)
|
2018-07-23 23:04:49 +02:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def mount(verbose: false)
|
2018-07-24 07:03:24 +02:00
|
|
|
Dir.mktmpdir do |mount_dir|
|
|
|
|
mount_dir = Pathname(mount_dir)
|
2018-07-23 23:04:49 +02:00
|
|
|
|
2018-09-02 16:15:09 +01:00
|
|
|
without_eula = system_command(
|
|
|
|
"hdiutil",
|
2018-11-02 17:18:07 +00:00
|
|
|
args: [
|
2020-09-19 21:20:25 +08:00
|
|
|
"attach", "-plist", "-nobrowse", "-readonly",
|
2018-09-02 16:15:09 +01:00
|
|
|
"-mountrandom", mount_dir, path
|
|
|
|
],
|
2018-11-02 17:18:07 +00:00
|
|
|
input: "qn\n",
|
2018-09-02 16:15:09 +01:00
|
|
|
print_stderr: false,
|
2018-11-02 17:18:07 +00:00
|
|
|
verbose: verbose,
|
2018-09-02 16:15:09 +01:00
|
|
|
)
|
2018-07-23 23:04:49 +02:00
|
|
|
|
|
|
|
# If mounting without agreeing to EULA succeeded, there is none.
|
|
|
|
plist = if without_eula.success?
|
|
|
|
without_eula.plist
|
|
|
|
else
|
2018-07-24 07:03:24 +02:00
|
|
|
cdr_path = mount_dir/path.basename.sub_ext(".cdr")
|
2018-07-23 23:04:49 +02:00
|
|
|
|
2018-11-02 11:44:00 +01:00
|
|
|
quiet_flag = "-quiet" unless verbose
|
|
|
|
|
2018-09-02 16:15:09 +01:00
|
|
|
system_command!(
|
|
|
|
"hdiutil",
|
2018-11-02 17:18:07 +00:00
|
|
|
args: [
|
2018-11-02 11:44:00 +01:00
|
|
|
"convert", *quiet_flag, "-format", "UDTO", "-o", cdr_path, path
|
2018-09-02 16:15:09 +01:00
|
|
|
],
|
|
|
|
verbose: verbose,
|
|
|
|
)
|
|
|
|
|
|
|
|
with_eula = system_command!(
|
|
|
|
"hdiutil",
|
2018-11-02 17:18:07 +00:00
|
|
|
args: [
|
2020-09-19 21:20:25 +08:00
|
|
|
"attach", "-plist", "-nobrowse", "-readonly",
|
2018-09-02 16:15:09 +01:00
|
|
|
"-mountrandom", mount_dir, cdr_path
|
|
|
|
],
|
|
|
|
verbose: verbose,
|
|
|
|
)
|
2018-07-23 23:04:49 +02:00
|
|
|
|
|
|
|
if verbose && !(eula_text = without_eula.stdout).empty?
|
2020-07-06 15:30:57 -04:00
|
|
|
ohai "Software License Agreement for '#{path}':", eula_text
|
2018-07-23 23:04:49 +02:00
|
|
|
end
|
|
|
|
|
|
|
|
with_eula.plist
|
|
|
|
end
|
|
|
|
|
2018-07-24 07:03:24 +02:00
|
|
|
mounts = if plist.respond_to?(:fetch)
|
|
|
|
plist.fetch("system-entities", [])
|
|
|
|
.map { |entity| entity["mount-point"] }
|
|
|
|
.compact
|
|
|
|
.map { |path| Mount.new(path) }
|
2018-07-23 23:04:49 +02:00
|
|
|
else
|
2018-07-24 07:03:24 +02:00
|
|
|
[]
|
2018-07-23 23:04:49 +02:00
|
|
|
end
|
|
|
|
|
2018-07-24 07:03:24 +02:00
|
|
|
begin
|
|
|
|
yield mounts
|
|
|
|
ensure
|
2018-07-24 18:43:20 +02:00
|
|
|
mounts.each do |mount|
|
|
|
|
mount.eject(verbose: verbose)
|
|
|
|
end
|
2018-07-23 23:04:49 +02:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|