Skip to content

Commit 6e98d4d

Browse files
committed
Convert quarantine script to use FFI rather than Swift
1 parent 9f40f32 commit 6e98d4d

4 files changed

Lines changed: 255 additions & 74 deletions

File tree

Library/Homebrew/cask/quarantine.rb

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ class SigningIdentity < T::Struct
2020
QUARANTINE_ATTRIBUTE = "com.apple.quarantine"
2121
USER_APPROVED_FLAG = 0x0040
2222

23-
QUARANTINE_SCRIPT = T.let((HOMEBREW_LIBRARY_PATH/"cask/utils/quarantine.swift").freeze, Pathname)
2423
COPY_XATTRS_SCRIPT = T.let((HOMEBREW_LIBRARY_PATH/"cask/utils/copy-xattrs.swift").freeze, Pathname)
2524

2625
sig { returns(T.nilable(T.any(String, Pathname))) }
@@ -70,7 +69,7 @@ def self.check_quarantine_support
7069
raise "unexpected nil swift" unless s
7170

7271
api_check = system_command(s,
73-
args: [*swift_target_args, QUARANTINE_SCRIPT],
72+
args: [*swift_target_args, COPY_XATTRS_SCRIPT],
7473
print_stderr: false)
7574

7675
exit_status = api_check.exit_status
@@ -205,26 +204,40 @@ def self.cask!(cask: nil, download_path: nil, action: true)
205204

206205
odebug "Quarantining #{download_path}"
207206

208-
raise "unexpected nil swift" unless swift
207+
require "os/mac/ffi"
209208

210-
quarantiner = system_command(T.must(swift),
211-
args: [
212-
*swift_target_args,
213-
QUARANTINE_SCRIPT,
214-
download_path,
215-
cask.url.to_s,
216-
cask.homepage.to_s,
217-
],
218-
print_stderr: false)
209+
path_cf_string = MacOS::FFI::CoreFoundation.string_create(download_path.to_s)
210+
raise CaskQuarantineError.new(download_path, "Failed to create CFString for path") if path_cf_string.null?
219211

220-
return if quarantiner.success?
212+
path_cf_url = MacOS::FFI::CoreFoundation.url_create_with_file_system_path(path_cf_string)
213+
raise CaskQuarantineError.new(download_path, "Failed to create CFURL for path") if path_cf_url.null?
221214

222-
case quarantiner.exit_status
223-
when 2
224-
raise CaskQuarantineError.new(download_path, "Insufficient parameters")
225-
else
226-
raise CaskQuarantineError.new(download_path, quarantiner.stderr)
215+
quarantine_agent_name = MacOS::FFI::CoreFoundation.string_create("Homebrew Cask")
216+
quarantine_data_url = MacOS::FFI::CoreFoundation.string_create(cask.url.to_s)
217+
quarantine_origin_url = MacOS::FFI::CoreFoundation.string_create(cask.homepage.to_s)
218+
if quarantine_agent_name.null? || quarantine_data_url.null? || quarantine_origin_url.null?
219+
raise CaskQuarantineError.new(download_path, "Failed to create CFString for quarantine properties")
227220
end
221+
222+
quarantine_dictionary = MacOS::FFI::CoreFoundation.dictionary_create(
223+
MacOS::FFI::LaunchServices.quarantine_agent_name_key => quarantine_agent_name,
224+
MacOS::FFI::LaunchServices.quarantine_type_key => MacOS::FFI::LaunchServices.quarantine_type_web_download,
225+
MacOS::FFI::LaunchServices.quarantine_data_url_key => quarantine_data_url,
226+
MacOS::FFI::LaunchServices.quarantine_origin_url_key => quarantine_origin_url,
227+
)
228+
if quarantine_dictionary.null?
229+
raise CaskQuarantineError.new(download_path, "Failed to create quarantine dictionary")
230+
end
231+
232+
success = MacOS::FFI::CoreFoundation.url_set_resource_property_for_key(
233+
path_cf_url,
234+
MacOS::FFI::CoreFoundation.url_quarantine_properties_key,
235+
quarantine_dictionary,
236+
)
237+
238+
return if success
239+
240+
raise CaskQuarantineError.new(download_path, "Failed to set quarantine properties for URL")
228241
end
229242

230243
sig { params(from: T.nilable(Pathname), to: T.nilable(Pathname)).void }

Library/Homebrew/cask/utils/quarantine.swift

Lines changed: 0 additions & 47 deletions
This file was deleted.

Library/Homebrew/os/mac/ffi.rb

Lines changed: 189 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,201 @@ module OS
77
module Mac
88
# Wrapping module for FFI calls to system libraries.
99
module FFI
10-
sig { returns(Fiddle::Handle) }
11-
private_class_method def self.libsystem
12-
@libsystem ||= T.let(Fiddle.dlopen("/usr/lib/libSystem.B.dylib"), T.nilable(Fiddle::Handle))
10+
# NativeLibrary provides helper methods for loading system libraries and accessing functions and constants.
11+
# Functions and constants are cached so they only need to be looked up once.
12+
module NativeLibrary
13+
private
14+
15+
sig { params(path: String).void }
16+
def use_library(path)
17+
@library_path = T.let(path.freeze, T.nilable(String))
18+
end
19+
20+
sig { returns(Fiddle::Handle) }
21+
def handle
22+
@handle ||= T.let(Fiddle.dlopen(T.must(@library_path)), T.nilable(Fiddle::Handle))
23+
end
24+
25+
sig { params(name: String, argument_types: T::Array[Integer], return_type: Integer).returns(Fiddle::Function) }
26+
def function(name, argument_types, return_type)
27+
@functions ||= T.let({}, T.nilable(T::Hash[String, Fiddle::Function]))
28+
@functions[name] ||= Fiddle::Function.new(handle[name], argument_types, return_type)
29+
end
30+
31+
sig { params(name: String, dereference: T::Boolean).returns(Fiddle::Pointer) }
32+
def constant(name, dereference: false)
33+
@constants ||= T.let({}, T.nilable(T::Hash[[String, T::Boolean], Fiddle::Pointer]))
34+
@constants[[name, dereference]] ||= begin
35+
pointer = Fiddle::Pointer.new(handle[name])
36+
dereference ? pointer.ptr : pointer
37+
end
38+
end
1339
end
1440

41+
extend NativeLibrary
42+
43+
use_library "/usr/lib/libSystem.B.dylib"
44+
45+
# mach-o/dyld.h:
46+
# bool _dyld_shared_cache_contains_path(const char* path);
1547
sig { params(path: String).returns(T::Boolean) }
1648
def self.dyld_shared_cache_contains_path(path)
17-
@dyld_shared_cache_contains_path ||= T.let(
18-
Fiddle::Function.new(
19-
libsystem["_dyld_shared_cache_contains_path"],
20-
[Fiddle::TYPE_CONST_STRING],
49+
function(
50+
"_dyld_shared_cache_contains_path",
51+
[Fiddle::TYPE_CONST_STRING],
52+
Fiddle::TYPE_BOOL,
53+
).call(path)
54+
end
55+
56+
# CoreFoundation.framework wrapper
57+
module CoreFoundation
58+
extend NativeLibrary
59+
60+
use_library "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation"
61+
62+
sig { params(ptr: Fiddle::Pointer).returns(Fiddle::Pointer) }
63+
private_class_method def self.autorelease(ptr)
64+
return ptr if ptr.null?
65+
66+
# CoreFoundation/CFBase.h:
67+
# void CFRelease(CFTypeRef cf);
68+
ptr.free = function("CFRelease", [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID)
69+
ptr
70+
end
71+
72+
# CoreFoundation/CFDictionary.h:
73+
# extern const CFDictionaryKeyCallBacks kCFTypeDictionaryKeyCallBacks;
74+
sig { returns(Fiddle::Pointer) }
75+
def self.type_dictionary_key_call_backs = constant("kCFTypeDictionaryKeyCallBacks")
76+
77+
# CoreFoundation/CFDictionary.h:
78+
# extern const CFDictionaryValueCallBacks kCFTypeDictionaryValueCallBacks
79+
sig { returns(Fiddle::Pointer) }
80+
def self.type_dictionary_value_call_backs = constant("kCFTypeDictionaryValueCallBacks")
81+
82+
# CoreFoundation/CFURL.h:
83+
# extern const CFStringRef kCFURLQuarantinePropertiesKey;
84+
sig { returns(Fiddle::Pointer) }
85+
def self.url_quarantine_properties_key = constant("kCFURLQuarantinePropertiesKey", dereference: true)
86+
87+
# CoreFoundation/CFString.h:
88+
# CFStringRef CFStringCreateWithCString(CFAllocatorRef alloc, const char *cStr, CFStringEncoding encoding);
89+
sig { params(string: String).returns(Fiddle::Pointer) }
90+
def self.string_create(string)
91+
cf_encoding = case string.encoding
92+
when Encoding::UTF_8
93+
0x08000100 # kCFStringEncodingUTF8
94+
when Encoding::US_ASCII
95+
0x0600 # kCFStringEncodingASCII
96+
when Encoding::ASCII_8BIT, Encoding::ISO8859_1
97+
# ASCII-8BIT could be anything, so just use Latin-1
98+
0x0201 # kCFStringEncodingISOLatin1
99+
else
100+
# Try convert to UTF-8 and move on
101+
string = string.encode(Encoding::UTF_8)
102+
0x08000100
103+
end
104+
105+
autorelease(
106+
function(
107+
"CFStringCreateWithCString",
108+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_CONST_STRING, Fiddle::TYPE_UINT32_T],
109+
Fiddle::TYPE_VOIDP,
110+
).call(nil, string, cf_encoding),
111+
)
112+
end
113+
114+
# CoreFoundation/CFDictionary.h:
115+
# CFDictionaryRef CFDictionaryCreate(
116+
# CFAllocatorRef allocator,
117+
# const void **keys,
118+
# const void **values,
119+
# CFIndex numValues,
120+
# const CFDictionaryKeyCallBacks *keyCallBacks,
121+
# const CFDictionaryValueCallBacks *valueCallBacks);
122+
sig { params(hash: T::Hash[Fiddle::Pointer, Fiddle::Pointer]).returns(Fiddle::Pointer) }
123+
def self.dictionary_create(hash)
124+
size = Fiddle::SIZEOF_VOIDP * hash.size
125+
Fiddle::Pointer.malloc(size, Fiddle::RUBY_FREE) do |keys|
126+
Fiddle::Pointer.malloc(size, Fiddle::RUBY_FREE) do |values|
127+
# Convert array of pointers to continous stream of pointers in the C buffer
128+
keys[0, size] = hash.keys.pack("J*")
129+
values[0, size] = hash.values.pack("J*")
130+
return autorelease(
131+
function(
132+
"CFDictionaryCreate",
133+
[
134+
Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP,
135+
Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP
136+
],
137+
Fiddle::TYPE_VOIDP,
138+
).call(
139+
nil, keys, values, hash.size, type_dictionary_key_call_backs, type_dictionary_value_call_backs
140+
),
141+
)
142+
end
143+
end
144+
end
145+
146+
# CoreFoundation/CFURL.h:
147+
# CFURLRef CFURLCreateWithFileSystemPath(CFAllocatorRef allocator,
148+
# CFStringRef filePath, CFURLPathStyle pathStyle, Boolean isDirectory);
149+
sig { params(path: Fiddle::Pointer).returns(Fiddle::Pointer) }
150+
def self.url_create_with_file_system_path(path)
151+
autorelease(
152+
function(
153+
"CFURLCreateWithFileSystemPath",
154+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG, Fiddle::TYPE_BOOL],
155+
Fiddle::TYPE_VOIDP,
156+
).call(nil, path, 0, false),
157+
)
158+
end
159+
160+
# CoreFoundation/CFURL.h:
161+
# Boolean CFURLSetResourcePropertyForKey(CFURLRef url, CFStringRef key, CFTypeRef value, CFErrorRef *error);
162+
sig { params(url: Fiddle::Pointer, key: Fiddle::Pointer, value: Fiddle::Pointer).returns(T::Boolean) }
163+
def self.url_set_resource_property_for_key(url, key, value)
164+
function(
165+
"CFURLSetResourcePropertyForKey",
166+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP],
21167
Fiddle::TYPE_BOOL,
22-
), T.nilable(Fiddle::Function)
168+
).call(url, key, value, nil)
169+
end
170+
end
171+
172+
# LaunchServices.framework wrapper
173+
module LaunchServices
174+
extend NativeLibrary
175+
176+
use_library(
177+
"/System/Library/Frameworks/CoreServices.framework/Versions/A/" \
178+
"Frameworks/LaunchServices.framework/Versions/A/LaunchServices",
23179
)
24-
@dyld_shared_cache_contains_path.call(path)
180+
181+
# LaunchServices/LSQuarantine.h:
182+
# extern const CFStringRef kLSQuarantineAgentNameKey;
183+
sig { returns(Fiddle::Pointer) }
184+
def self.quarantine_agent_name_key = constant("kLSQuarantineAgentNameKey", dereference: true)
185+
186+
# LaunchServices/LSQuarantine.h:
187+
# extern const CFStringRef kLSQuarantineTypeKey;
188+
sig { returns(Fiddle::Pointer) }
189+
def self.quarantine_type_key = constant("kLSQuarantineTypeKey", dereference: true)
190+
191+
# LaunchServices/LSQuarantine.h:
192+
# extern const CFStringRef kLSQuarantineTypeWebDownload;
193+
sig { returns(Fiddle::Pointer) }
194+
def self.quarantine_type_web_download = constant("kLSQuarantineTypeWebDownload", dereference: true)
195+
196+
# LaunchServices/LSQuarantine.h:
197+
# extern const CFStringRef kLSQuarantineDataURLKey;
198+
sig { returns(Fiddle::Pointer) }
199+
def self.quarantine_data_url_key = constant("kLSQuarantineDataURLKey", dereference: true)
200+
201+
# LaunchServices/LSQuarantine.h:
202+
# extern const CFStringRef kLSQuarantineOriginURLKey;
203+
sig { returns(Fiddle::Pointer) }
204+
def self.quarantine_origin_url_key = constant("kLSQuarantineOriginURLKey", dereference: true)
25205
end
26206
end
27207
end

Library/Homebrew/test/cask/quarantine_spec.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,41 @@
22
# frozen_string_literal: true
33

44
RSpec.describe Cask::Quarantine do
5+
describe ".cask!", :needs_macos do
6+
let(:cask) do
7+
instance_double(
8+
Cask::Cask,
9+
url: "https://example.com/download",
10+
homepage: "https://example.com",
11+
)
12+
end
13+
14+
it "sets the quarantine attribute on a file in a temporary directory" do
15+
mktmpdir do |tmpdir|
16+
download_path = tmpdir/"Test.dmg"
17+
download_path.write("test")
18+
19+
expect(described_class.status(download_path)).to eq("")
20+
21+
described_class.cask!(cask:, download_path:)
22+
23+
expect(described_class.status(download_path)).to match(
24+
/\A[0-9a-f]{4};[0-9a-f]+;Homebrew\\x20Cask;[0-9A-F-]{36}\z/i,
25+
)
26+
end
27+
end
28+
29+
it "raises when the quarantine properties cannot be written" do
30+
mktmpdir do |tmpdir|
31+
download_path = tmpdir/"missing.dmg"
32+
33+
expect do
34+
described_class.cask!(cask:, download_path:)
35+
end.to raise_error(Cask::CaskQuarantineError, /Failed to set quarantine properties for URL/)
36+
end
37+
end
38+
end
39+
540
describe ".user_approved?" do
641
let(:file) { Pathname("/tmp/Test.app") }
742

0 commit comments

Comments
 (0)