Skip to content

Commit 97d7b31

Browse files
authored
fix: correct cask upgrade silent failures and version drift (#114)
- `bru upgrade` no longer silently downgrades casks on recent macOS when an older OS has a variation override. - Upgrades now actually replace the .app in /Applications instead of warning and reporting fake success. - Failed installs no longer leave stranded caskroom directories. - Download/HTTP errors now include the status code and URL.
1 parent 7a3934d commit 97d7b31

5 files changed

Lines changed: 494 additions & 94 deletions

File tree

src/cask.zig

Lines changed: 258 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const std = @import("std");
2+
const builtin = @import("builtin");
23
const mem = std.mem;
34
const Allocator = mem.Allocator;
45
const HttpClient = @import("http.zig").HttpClient;
@@ -19,7 +20,16 @@ pub const CaskInfo = struct {
1920

2021
/// Parse a JSON array of cask objects into a slice of CaskInfo.
2122
/// The caller owns the returned slice and must free each entry with freeCask.
23+
/// Uses the current machine's macOS for variation resolution.
2224
pub fn parseCaskJson(allocator: Allocator, json_bytes: []const u8) ![]CaskInfo {
25+
var tag_buf: [64]u8 = undefined;
26+
const tag = currentMacOSVariationTag(&tag_buf);
27+
return parseCaskJsonWithTag(allocator, json_bytes, tag);
28+
}
29+
30+
/// Same as parseCaskJson but with the variation tag passed explicitly.
31+
/// Useful for tests and for callers that want to resolve for a non-current OS.
32+
pub fn parseCaskJsonWithTag(allocator: Allocator, json_bytes: []const u8, variation_tag: []const u8) ![]CaskInfo {
2333
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_bytes, .{
2434
.allocate = .alloc_always,
2535
});
@@ -44,15 +54,30 @@ pub fn parseCaskJson(allocator: Allocator, json_bytes: []const u8) ![]CaskInfo {
4454
else => continue,
4555
};
4656

47-
const info = parseOneCask(allocator, obj) catch continue;
57+
const info = parseOneCask(allocator, obj, variation_tag) catch continue;
4858
result.appendAssumeCapacity(info);
4959
}
5060

5161
return try result.toOwnedSlice(allocator);
5262
}
5363

54-
/// Parse a single cask JSON object into a CaskInfo.
55-
fn parseOneCask(allocator: Allocator, obj: std.json.ObjectMap) !CaskInfo {
64+
/// Parse a single cask JSON object's bytes into a CaskInfo, applying the given
65+
/// variation tag. Public so tests can drive parseOneCask directly.
66+
pub fn parseSingleCaskJson(allocator: Allocator, json_bytes: []const u8, variation_tag: []const u8) !CaskInfo {
67+
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_bytes, .{
68+
.allocate = .alloc_always,
69+
});
70+
defer parsed.deinit();
71+
const obj = switch (parsed.value) {
72+
.object => |o| o,
73+
else => return error.InvalidJson,
74+
};
75+
return parseOneCask(allocator, obj, variation_tag);
76+
}
77+
78+
/// Parse a single cask JSON object into a CaskInfo. Applies the platform
79+
/// variation override iff `variation_tag` is present in `variations`.
80+
fn parseOneCask(allocator: Allocator, obj: std.json.ObjectMap, variation_tag: []const u8) !CaskInfo {
5681
const token = try allocator.dupe(u8, jsonStr(obj, "token") orelse return error.MissingField);
5782
errdefer allocator.free(token);
5883

@@ -81,13 +106,23 @@ fn parseOneCask(allocator: Allocator, obj: std.json.ObjectMap) !CaskInfo {
81106
const homepage = try allocator.dupe(u8, jsonStr(obj, "homepage") orelse "");
82107
errdefer allocator.free(homepage);
83108

84-
const version = try allocator.dupe(u8, jsonStr(obj, "version") orelse "");
109+
// Start from top-level, then override with the current-OS variation iff present.
110+
var version_src: []const u8 = jsonStr(obj, "version") orelse "";
111+
var url_src: []const u8 = jsonStr(obj, "url") orelse "";
112+
var sha256_src: []const u8 = jsonStr(obj, "sha256") orelse "";
113+
if (variationOverrideObject(obj, variation_tag)) |tag_obj| {
114+
if (jsonStr(tag_obj, "version")) |v| version_src = v;
115+
if (jsonStr(tag_obj, "url")) |u| url_src = u;
116+
if (jsonStr(tag_obj, "sha256")) |s| sha256_src = s;
117+
}
118+
119+
const version = try allocator.dupe(u8, version_src);
85120
errdefer allocator.free(version);
86121

87-
const url = try allocator.dupe(u8, jsonStr(obj, "url") orelse "");
122+
const url = try allocator.dupe(u8, url_src);
88123
errdefer allocator.free(url);
89124

90-
const sha256 = try allocator.dupe(u8, jsonStr(obj, "sha256") orelse "");
125+
const sha256 = try allocator.dupe(u8, sha256_src);
91126
// No errdefer needed for the last allocation before the return.
92127

93128
const deprecated = jsonBool(obj, "deprecated") orelse false;
@@ -168,30 +203,53 @@ pub const ResolvedCask = struct {
168203
apps: []AppArtifact,
169204
};
170205

171-
/// Platform tags to try when resolving cask variations, in priority order.
172-
/// Tries the most specific first (arch + OS version), then falls back to
173-
/// less specific tags. The first match wins.
174-
fn platformVariationTags() []const []const u8 {
175-
const arch = @import("builtin").target.cpu.arch;
176-
177-
if (arch == .aarch64) {
178-
return &.{
179-
"arm64_tahoe",
180-
"arm64_sequoia",
181-
"arm64_sonoma",
182-
"arm64_ventura",
183-
"arm64_monterey",
184-
"arm64_big_sur",
185-
};
186-
}
187-
return &.{
188-
"tahoe",
189-
"sequoia",
190-
"sonoma",
191-
"ventura",
192-
"monterey",
193-
"big_sur",
206+
/// Map a Darwin kernel major version and CPU arch to the Homebrew cask
207+
/// variation tag for the user's actual macOS. Returns "" when the kernel
208+
/// release is unknown — callers treat empty as "no variation, use top-level".
209+
///
210+
/// Darwin → macOS mapping:
211+
/// 25 → Tahoe (macOS 26) 22 → Ventura (13)
212+
/// 24 → Sequoia (15) 21 → Monterey (12)
213+
/// 23 → Sonoma (14) 20 → Big Sur (11)
214+
fn darwinReleaseToVariationTag(major: u32, arch: std.Target.Cpu.Arch, buf: []u8) []const u8 {
215+
const name: []const u8 = switch (major) {
216+
25 => "tahoe",
217+
24 => "sequoia",
218+
23 => "sonoma",
219+
22 => "ventura",
220+
21 => "monterey",
221+
20 => "big_sur",
222+
else => return "",
194223
};
224+
const prefix: []const u8 = if (arch == .aarch64) "arm64_" else "";
225+
return std.fmt.bufPrint(buf, "{s}{s}", .{ prefix, name }) catch "";
226+
}
227+
228+
/// Return the cask-variation tag for THIS machine (e.g. "arm64_tahoe"),
229+
/// or "" if it cannot be determined. Uses uname() — no subprocess.
230+
///
231+
/// Match policy: a variation must match this exact tag to override the
232+
/// top-level fields. Previously the resolver walked all known tags new-to-old
233+
/// and applied the first match, which downgraded current-macOS users when a
234+
/// cask had variations only for older OSes (e.g. raycast: top-level 1.104.18,
235+
/// arm64_monterey override 1.94.4 — a Tahoe user was incorrectly handed 1.94.4).
236+
pub fn currentMacOSVariationTag(buf: []u8) []const u8 {
237+
const uname = std.posix.uname();
238+
const release = std.mem.sliceTo(&uname.release, 0);
239+
var it = std.mem.splitScalar(u8, release, '.');
240+
const major_str = it.next() orelse return "";
241+
const major = std.fmt.parseInt(u32, major_str, 10) catch return "";
242+
return darwinReleaseToVariationTag(major, builtin.target.cpu.arch, buf);
243+
}
244+
245+
/// If `obj.variations[variation_tag]` exists and is an object, return it.
246+
/// Otherwise null — caller falls back to top-level fields.
247+
fn variationOverrideObject(obj: std.json.ObjectMap, variation_tag: []const u8) ?std.json.ObjectMap {
248+
if (variation_tag.len == 0) return null;
249+
const var_val = obj.get("variations") orelse return null;
250+
const variations = asObject(var_val) orelse return null;
251+
const tag_val = variations.get(variation_tag) orelse return null;
252+
return asObject(tag_val);
195253
}
196254

197255
/// Fetch and resolve a cask from the per-cask API.
@@ -209,8 +267,18 @@ pub fn fetchAndResolveCask(allocator: Allocator, http_client: *HttpClient, token
209267
return try parseResolvedCask(allocator, json_bytes);
210268
}
211269

212-
/// Parse a per-cask API JSON response into a ResolvedCask.
270+
/// Parse a per-cask API JSON response, applying the current machine's macOS
271+
/// variation. See parseResolvedCaskWithTag for tag semantics.
213272
pub fn parseResolvedCask(allocator: Allocator, json_bytes: []const u8) !ResolvedCask {
273+
var tag_buf: [64]u8 = undefined;
274+
const tag = currentMacOSVariationTag(&tag_buf);
275+
return parseResolvedCaskWithTag(allocator, json_bytes, tag);
276+
}
277+
278+
/// Parse a per-cask API JSON response into a ResolvedCask, applying the
279+
/// variation block for `variation_tag` iff it exists. Pass "" to skip
280+
/// variations entirely.
281+
pub fn parseResolvedCaskWithTag(allocator: Allocator, json_bytes: []const u8, variation_tag: []const u8) !ResolvedCask {
214282
const parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_bytes, .{
215283
.allocate = .alloc_always,
216284
});
@@ -224,46 +292,25 @@ pub fn parseResolvedCask(allocator: Allocator, json_bytes: []const u8) !Resolved
224292
const result_token = try allocator.dupe(u8, jsonStr(obj, "token") orelse return error.MissingField);
225293
errdefer allocator.free(result_token);
226294

227-
// Start with top-level url/sha256/version, then override from variations.
228-
var url = try allocator.dupe(u8, jsonStr(obj, "url") orelse "");
295+
// Resolve url/sha256/version against the variation for this macOS, if any.
296+
var url_src: []const u8 = jsonStr(obj, "url") orelse "";
297+
var sha256_src: []const u8 = jsonStr(obj, "sha256") orelse "";
298+
var version_src: []const u8 = jsonStr(obj, "version") orelse "";
299+
if (variationOverrideObject(obj, variation_tag)) |tag_obj| {
300+
if (jsonStr(tag_obj, "url")) |v| url_src = v;
301+
if (jsonStr(tag_obj, "sha256")) |v| sha256_src = v;
302+
if (jsonStr(tag_obj, "version")) |v| version_src = v;
303+
}
304+
305+
const url = try allocator.dupe(u8, url_src);
229306
errdefer allocator.free(url);
230307

231-
var sha256 = try allocator.dupe(u8, jsonStr(obj, "sha256") orelse "");
308+
const sha256 = try allocator.dupe(u8, sha256_src);
232309
errdefer allocator.free(sha256);
233310

234-
var version = try allocator.dupe(u8, jsonStr(obj, "version") orelse "");
311+
const version = try allocator.dupe(u8, version_src);
235312
errdefer allocator.free(version);
236313

237-
// Check variations for platform-specific overrides.
238-
// Allocate new values before freeing old ones to avoid double-free on OOM.
239-
if (obj.get("variations")) |var_val| {
240-
if (asObject(var_val)) |variations| {
241-
const tags = platformVariationTags();
242-
for (tags) |tag| {
243-
if (variations.get(tag)) |tag_val| {
244-
if (asObject(tag_val)) |tag_obj| {
245-
if (jsonStr(tag_obj, "url")) |v_url| {
246-
const new_url = try allocator.dupe(u8, v_url);
247-
allocator.free(url);
248-
url = new_url;
249-
}
250-
if (jsonStr(tag_obj, "sha256")) |v_sha| {
251-
const new_sha = try allocator.dupe(u8, v_sha);
252-
allocator.free(sha256);
253-
sha256 = new_sha;
254-
}
255-
if (jsonStr(tag_obj, "version")) |v_ver| {
256-
const new_ver = try allocator.dupe(u8, v_ver);
257-
allocator.free(version);
258-
version = new_ver;
259-
}
260-
break;
261-
}
262-
}
263-
}
264-
}
265-
}
266-
267314
// Parse name (first element of name array).
268315
const result_name = blk: {
269316
const name_val = obj.get("name") orelse break :blk try allocator.dupe(u8, "");
@@ -685,3 +732,150 @@ test "cleanArtifactPath strips HOMEBREW_PREFIX/Caskroom prefix" {
685732
test "cleanArtifactPath preserves plain paths" {
686733
try std.testing.expectEqualStrings("studio", cleanArtifactPath("studio"));
687734
}
735+
736+
// ---------------------------------------------------------------------------
737+
// Bug 2: variation-tag selection must match the user's actual macOS.
738+
// Previously the code iterated platformVariationTags() new-to-old and applied
739+
// the first match, which downgraded users on a recent macOS when a cask had
740+
// variations only for older OSes (e.g. raycast 1.99.3 -> 1.94.4).
741+
// ---------------------------------------------------------------------------
742+
743+
test "parseSingleCaskJson without variations keeps top-level fields" {
744+
const allocator = std.testing.allocator;
745+
const json_bytes =
746+
\\{
747+
\\ "token": "raycast",
748+
\\ "name": ["Raycast"],
749+
\\ "url": "https://example.com/raycast-1.104.18.dmg",
750+
\\ "version": "1.104.18",
751+
\\ "sha256": "newhash"
752+
\\}
753+
;
754+
const info = try parseSingleCaskJson(allocator, json_bytes, "arm64_tahoe");
755+
defer freeCask(allocator, info);
756+
try std.testing.expectEqualStrings("1.104.18", info.version);
757+
try std.testing.expectEqualStrings("https://example.com/raycast-1.104.18.dmg", info.url);
758+
try std.testing.expectEqualStrings("newhash", info.sha256);
759+
}
760+
761+
test "parseSingleCaskJson applies variation only when tag matches user OS" {
762+
const allocator = std.testing.allocator;
763+
// Top-level is the current Raycast; variations cap older macOS at 1.94.4.
764+
// A Tahoe user must NOT pick up monterey's downgrade.
765+
const json_bytes =
766+
\\{
767+
\\ "token": "raycast",
768+
\\ "name": ["Raycast"],
769+
\\ "url": "https://example.com/raycast-1.104.18.dmg",
770+
\\ "version": "1.104.18",
771+
\\ "sha256": "newhash",
772+
\\ "variations": {
773+
\\ "arm64_monterey": {
774+
\\ "url": "https://example.com/raycast-1.94.4.dmg",
775+
\\ "version": "1.94.4",
776+
\\ "sha256": "oldhash"
777+
\\ }
778+
\\ }
779+
\\}
780+
;
781+
const tahoe = try parseSingleCaskJson(allocator, json_bytes, "arm64_tahoe");
782+
defer freeCask(allocator, tahoe);
783+
try std.testing.expectEqualStrings("1.104.18", tahoe.version);
784+
try std.testing.expectEqualStrings("newhash", tahoe.sha256);
785+
}
786+
787+
test "parseSingleCaskJson uses variation when tag matches" {
788+
const allocator = std.testing.allocator;
789+
const json_bytes =
790+
\\{
791+
\\ "token": "raycast",
792+
\\ "name": ["Raycast"],
793+
\\ "url": "https://example.com/raycast-1.104.18.dmg",
794+
\\ "version": "1.104.18",
795+
\\ "sha256": "newhash",
796+
\\ "variations": {
797+
\\ "arm64_monterey": {
798+
\\ "url": "https://example.com/raycast-1.94.4.dmg",
799+
\\ "version": "1.94.4",
800+
\\ "sha256": "oldhash"
801+
\\ }
802+
\\ }
803+
\\}
804+
;
805+
const monterey = try parseSingleCaskJson(allocator, json_bytes, "arm64_monterey");
806+
defer freeCask(allocator, monterey);
807+
try std.testing.expectEqualStrings("1.94.4", monterey.version);
808+
try std.testing.expectEqualStrings("oldhash", monterey.sha256);
809+
}
810+
811+
test "parseResolvedCaskWithTag ignores non-matching variation" {
812+
const allocator = std.testing.allocator;
813+
const json_bytes =
814+
\\{
815+
\\ "token": "raycast",
816+
\\ "name": ["Raycast"],
817+
\\ "url": "https://example.com/raycast-1.104.18.dmg",
818+
\\ "version": "1.104.18",
819+
\\ "sha256": "newhash",
820+
\\ "variations": {
821+
\\ "arm64_monterey": {
822+
\\ "url": "https://example.com/raycast-1.94.4.dmg",
823+
\\ "version": "1.94.4",
824+
\\ "sha256": "oldhash"
825+
\\ }
826+
\\ },
827+
\\ "artifacts": [{"app": ["Raycast.app"]}]
828+
\\}
829+
;
830+
const resolved = try parseResolvedCaskWithTag(allocator, json_bytes, "arm64_tahoe");
831+
defer freeResolvedCask(allocator, resolved);
832+
try std.testing.expectEqualStrings("1.104.18", resolved.version);
833+
}
834+
835+
test "parseResolvedCaskWithTag picks matching variation" {
836+
const allocator = std.testing.allocator;
837+
const json_bytes =
838+
\\{
839+
\\ "token": "raycast",
840+
\\ "name": ["Raycast"],
841+
\\ "url": "https://example.com/raycast-1.104.18.dmg",
842+
\\ "version": "1.104.18",
843+
\\ "sha256": "newhash",
844+
\\ "variations": {
845+
\\ "arm64_monterey": {
846+
\\ "url": "https://example.com/raycast-1.94.4.dmg",
847+
\\ "version": "1.94.4",
848+
\\ "sha256": "oldhash"
849+
\\ }
850+
\\ },
851+
\\ "artifacts": [{"app": ["Raycast.app"]}]
852+
\\}
853+
;
854+
const resolved = try parseResolvedCaskWithTag(allocator, json_bytes, "arm64_monterey");
855+
defer freeResolvedCask(allocator, resolved);
856+
try std.testing.expectEqualStrings("1.94.4", resolved.version);
857+
}
858+
859+
test "currentMacOSVariationTag identifies Tahoe from Darwin 25" {
860+
var buf: [64]u8 = undefined;
861+
const tag = darwinReleaseToVariationTag(25, .aarch64, &buf);
862+
try std.testing.expectEqualStrings("arm64_tahoe", tag);
863+
}
864+
865+
test "currentMacOSVariationTag identifies Sequoia from Darwin 24" {
866+
var buf: [64]u8 = undefined;
867+
const tag = darwinReleaseToVariationTag(24, .aarch64, &buf);
868+
try std.testing.expectEqualStrings("arm64_sequoia", tag);
869+
}
870+
871+
test "currentMacOSVariationTag handles intel arch" {
872+
var buf: [64]u8 = undefined;
873+
const tag = darwinReleaseToVariationTag(23, .x86_64, &buf);
874+
try std.testing.expectEqualStrings("sonoma", tag);
875+
}
876+
877+
test "currentMacOSVariationTag returns empty for unknown release" {
878+
var buf: [64]u8 = undefined;
879+
const tag = darwinReleaseToVariationTag(99, .aarch64, &buf);
880+
try std.testing.expectEqualStrings("", tag);
881+
}

0 commit comments

Comments
 (0)