@intincrab/updiff 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,10 +16,14 @@ camera, register something that starts at login, ship a new helper that runs as
16
16
  bundle a new analytics package, or start talking to a new service. updiff takes a
17
17
  snapshot of each app and, after every update, shows you exactly what changed inside it.
18
18
 
19
- - **Read-only.** It never runs the app, never changes it and never touches the network.
20
- - **Local.** Snapshots and reports stay in `~/.updiff`. Nothing is uploaded.
21
- - **Fast.** The first snapshot of 28 apps took 11 s on an Apple Silicon Mac; later checks
22
- skip unchanged apps and take about 0.1 s.
19
+ It also covers what updates without asking: **browser and editor extensions**, and
20
+ **installer packages** you're about to run.
21
+
22
+ - **Read-only.** It never runs the app, an installer or its scripts, and never changes them.
23
+ - **Local.** Snapshots and reports stay in `~/.updiff`. Nothing is uploaded. Only `fetch`
24
+ downloads anything, and Gatekeeper may ask Apple whether an app is notarized.
25
+ - **Fast.** The first snapshot of 28 apps takes about 40 s on an Apple Silicon Mac (11 s
26
+ with `--no-gatekeeper`); later checks skip unchanged apps and take about 0.1 s.
23
27
 
24
28
  ## What it looks like
25
29
 
@@ -131,7 +135,27 @@ updiff agent install # runs `updiff check` whenever your app folders change
131
135
 
132
136
  The agent waits until an update has finished writing, then posts a notification titled
133
137
  "Updiff: Google Chrome 156.0.8078.80" that says "0 high, 2 notable changes". The full
134
- report is saved in `~/.updiff/reports/`.
138
+ report is saved in `~/.updiff/reports/`. It also checks your browser and editor extensions.
139
+
140
+ No baseline yet? Compare an installed app with its previous release, downloaded from
141
+ Homebrew's history and checked against the checksum Homebrew recorded:
142
+
143
+ ```bash
144
+ updiff fetch Ghostty # Ghostty 1.3.0 → 1.3.1: 0 high · 0 notable · 2 info
145
+ ```
146
+
147
+ Before running an installer, see what it would do:
148
+
149
+ ```bash
150
+ updiff pkg ~/Downloads/Installer.pkg
151
+ ```
152
+
153
+ And see what your extensions can reach, or what changed since last time:
154
+
155
+ ```bash
156
+ updiff extensions
157
+ updiff extensions check
158
+ ```
135
159
 
136
160
  ## Commands
137
161
 
@@ -143,6 +167,9 @@ report is saved in `~/.updiff/reports/`.
143
167
  | `updiff diff <app>` | If the installed app changed since its newest snapshot, compare the two; otherwise compare its two newest snapshots. |
144
168
  | `updiff diff <a> <b>` | Compare any two things: `.app` bundles, snapshot `.json` files or stored apps. Good for a downloaded beta against the installed version. |
145
169
  | `updiff list [app]` | List stored apps, or one app's snapshots. |
170
+ | `updiff fetch <app> [version]` | Download an earlier release from the app's Homebrew cask history, verify it against the checksum Homebrew pinned, store it as a snapshot and compare it with the installed app. With no version, the release before the installed one. `--list` shows the history. |
171
+ | `updiff pkg <installer.pkg> [older.pkg]` | Show what an installer package would do before you run it: scripts with their text, installer JavaScript, apps, launch daemons and agents (including ones apps register from inside their bundle), privileged helpers, system extensions, everything written outside /Applications, signature and Gatekeeper's verdict. With two packages, what changed. Nothing is installed or run. |
172
+ | `updiff extensions [check]` | List browser and editor extensions with what they can reach. `check` reports what changed since the last check. |
146
173
  | `updiff agent install \| uninstall \| status` | Manage the LaunchAgent that runs `check` after updates. |
147
174
 
148
175
  `<app>` can be a path (`/Applications/Slack.app`), a name from your Applications folders
@@ -162,6 +189,10 @@ report is saved in `~/.updiff/reports/`.
162
189
  | `--home <dir>` | all | Data folder. Default `~/.updiff`, or `$UPDIFF_HOME`. |
163
190
  | `--keep <n>` | `snapshot`, `check` | Snapshots kept per app (default 10). |
164
191
  | `--notify` | `check` | Post a notification for each updated app. |
192
+ | `--extensions` | `check` | Also check browser and editor extensions (the agent does). |
193
+ | `--no-gatekeeper` | scanning | Skip the Gatekeeper/notarization check (faster). |
194
+ | `--cask <name>` | `fetch` | The Homebrew cask, when it isn't the app's name. |
195
+ | `--list` | `fetch` | List the releases in the cask's history. |
165
196
  | `--settle <seconds>` | `check` | Wait until no app has changed for this long before scanning. |
166
197
  | `--quiet`, `--no-color` | all | Print less; plain text. `NO_COLOR` is respected too. |
167
198
  | `--interval <seconds>` | `agent install` | Fallback timer between checks (default 21600, i.e. 6 hours). |
@@ -174,17 +205,26 @@ Exit codes: `0` success, `2` usage error (unknown app, bad option), `1` unexpect
174
205
 
175
206
  | | High | Notable | Info |
176
207
  |---|---|---|---|
177
- | **Signing** | different developer team; no longer developer-signed; hardened runtime removed | signing certificate changed | hardened runtime added |
208
+ | **Signing** | different developer team; no longer developer-signed; hardened runtime removed; no longer notarized; Gatekeeper rejects it or can't verify its signature | signing certificate changed; distribution changed (Developer ID ↔ App Store) | hardened runtime added; notarization ticket stapled or unstapled |
178
209
  | **Entitlements**, for every executable in the bundle | camera, microphone, location, contacts, calendars, photos, Apple Events, USB, Bluetooth, library validation off, debugging, Endpoint Security, network extensions, sandbox exceptions; sandbox removed | any other new entitlement; new values in app groups, keychain groups or associated domains | removals |
179
210
  | **Privacy prompts** (`NS…UsageDescription`) | a new prompt | | reworded or removed prompts |
180
211
  | **Background** | new LaunchAgent or LaunchDaemon, login item, privileged helper, system extension | changed background item | removals |
181
- | **Updater** (Sparkle) | update feed moved; update signing key changed | feed added or removed | |
212
+ | **Updater** (Sparkle) | update feed moved; update signing key changed | feed added or removed; feed set in the code changed | |
182
213
  | **Network** | App Transport Security allows plain HTTP | new ATS exception domains | |
183
214
  | **Code** | | new helper apps, XPC services, app extensions, plug-ins, executables, native Node modules, frameworks | framework versions; removals |
184
215
  | **Electron** | | Electron version; new bundled npm packages | package version changes and removals |
185
216
  | **Hostnames** | | new domains | new hosts under domains already present; domains no longer mentioned |
186
217
  | **URL schemes** | | new `scheme://` handlers | removals |
187
- | **Identity** | the two sides are different apps | Dock icon shown or hidden | version, minimum macOS, size |
218
+ | **Identity** | the two sides are different apps | Dock icon shown or hidden; moved to or from the Mac App Store | version, minimum macOS, size |
219
+ | **Browser extensions** | access to all websites; sensitive APIs (cookies, history, webRequest, debugger, nativeMessaging, proxy…); a different update server; a new extension with access to all websites | other new permissions; access to specific new sites; a new extension | low-reach permissions (storage, alarms…); optional permissions; removals; versions |
220
+ | **Editor extensions** (VS Code, Cursor, Windsurf, VSCodium) | starts installing other extensions | now starts with every window; new npm packages; new native code; new domains in the code; a new extension | removals; versions |
221
+
222
+ Gatekeeper's verdict comes from `spctl`, which may contact Apple for apps without a
223
+ stapled notarization ticket. Sparkle feeds are read from `Info.plist` and, for apps that
224
+ set them in code (Ghostty, ChatGPT), from URLs in the binary; `scan` also shows this Mac's
225
+ update settings for the app. Binaries that are FairPlay-encrypted (iPhone and iPad apps)
226
+ are flagged, because their strings can't be read. Mac App Store apps for macOS are not
227
+ encrypted, so their hostnames are read like any other app's.
188
228
 
189
229
  Code inside a bundle is matched between versions by its path and, if that fails, by its
190
230
  bundle id. So a renamed helper isn't reported as removed plus added, and Chrome's
@@ -217,6 +257,8 @@ Framework binaries are only searched with `--deep`. Electron apps keep their own
217
257
  ~/.updiff/
218
258
  ├── snapshots/<bundle id>/<time>__<version>__<build>.json one JSON file per snapshot
219
259
  ├── reports/<bundle id>/<time>__<from>__to__<to>.md one report per detected update
260
+ ├── extensions/<time>.json installed extensions (last 10)
261
+ ├── reports/extensions/<time>__<from>__to__<to>.md one report per extension change
220
262
  └── agent.log output of background checks
221
263
  ```
222
264
 
@@ -227,12 +269,14 @@ Delete the folder at any time to start over.
227
269
  ## Privacy and safety
228
270
 
229
271
  - updiff reads files and runs Apple's own tools:
230
- - `plutil` to read plists
231
- - `codesign` to read signatures and entitlements
232
- - `osascript` for notifications
233
- - `launchctl` for the agent
234
- - It never runs app code.
235
- - It never makes network requests, and nothing it records leaves your Mac.
272
+ - `plutil` and `defaults` to read plists and an app's update settings
273
+ - `codesign`, `spctl` and `stapler` to read signatures, Gatekeeper's verdict and
274
+ notarization tickets
275
+ - `hdiutil` (read-only) and `pkgutil --expand-full` to open disk images and packages
276
+ - `osascript` for notifications, `launchctl` for the agent
277
+ - It never runs app code, installer scripts or installer JavaScript.
278
+ - Nothing it records leaves your Mac. `fetch` downloads from Homebrew, GitHub and the
279
+ app's vendor; `spctl` may ask Apple about notarization. Nothing else uses the network.
236
280
  - The agent is a LaunchAgent in your user account (`dev.updiff.check`). It runs with your
237
281
  permissions, at low priority. `updiff agent install --dry-run` shows exactly what it
238
282
  installs; `updiff agent uninstall` removes it.
@@ -243,10 +287,12 @@ Delete the folder at any time to start over.
243
287
  the bundle, so updiff can't see them.
244
288
  - **Hostnames are pattern-matched.** Hosts assembled at runtime (`"api." + region + ".x.com"`)
245
289
  are missed, and a mentioned host is not necessarily contacted.
246
- - **The first update needs a baseline.** Run `updiff snapshot` before updating. You can
247
- also diff a downloaded copy of the old version against the installed one.
248
- - **Not covered yet:** Mac App Store receipts, Safari web extension manifests, managed
249
- browser policies and installer `.pkg` scripts.
290
+ - **Baselines.** `check` compares with the last snapshot. For an app you never snapshotted,
291
+ `updiff fetch` gets its previous release, if the app is in Homebrew.
292
+ - **Update feeds built at runtime** (AltTab assembles its URL from pieces) can't be found;
293
+ `scan` says when Sparkle is bundled but no feed was found.
294
+ - **Not covered yet:** Safari web extensions' permissions, managed browser policies, and
295
+ extensions for JetBrains IDEs.
250
296
 
251
297
  ## The ledger
252
298
 
package/dist/agent.js CHANGED
@@ -9,7 +9,7 @@ export function launchAgentsDir() {
9
9
  return process.env.UPDIFF_LAUNCH_AGENTS_DIR || join(homedir(), 'Library', 'LaunchAgents');
10
10
  }
11
11
  export function agentPlist(o) {
12
- const args = [o.nodePath, o.cliPath, 'check', '--notify', '--quiet', '--settle', '90', '--home', o.home];
12
+ const args = [o.nodePath, o.cliPath, 'check', '--extensions', '--notify', '--quiet', '--settle', '90', '--home', o.home];
13
13
  for (const d of o.appsDirs)
14
14
  args.push('--apps-dir', d);
15
15
  return toPlistXml({
package/dist/bundle.js CHANGED
@@ -2,9 +2,11 @@ import { createHash } from 'node:crypto';
2
2
  import { open, readdir, readFile, readlink, lstat, realpath, stat } from 'node:fs/promises';
3
3
  import { basename, join } from 'node:path';
4
4
  import { AsarArchive } from "./asar.js";
5
- import { readEntitlements, readSigning } from "./codesign.js";
6
- import { extractHosts, mergeHosts } from "./hosts.js";
7
- import { isDict, readPlistFile, str } from "./plist.js";
5
+ import { readEntitlements, readNotarization, readSigning } from "./codesign.js";
6
+ import { run } from "./exec.js";
7
+ import { extractFeedUrls, extractHosts, mergeHosts } from "./hosts.js";
8
+ import { isEncryptedMachO } from "./macho.js";
9
+ import { isDict, parsePlistXml, readPlistFile, str } from "./plist.js";
8
10
  import { SNAPSHOT_SCHEMA, } from "./types.js";
9
11
  import { mapLimit, sortedUnique } from "./util.js";
10
12
  import { TOOL_NAME, TOOL_VERSION } from "./version.js";
@@ -208,6 +210,35 @@ async function fingerprintOf(appPath, infoPath, mainExe) {
208
210
  }
209
211
  return hash.digest('hex').slice(0, 32);
210
212
  }
213
+ /** This Mac's update settings for an app, from its user defaults domain. */
214
+ async function readLocalSettings(bundleId) {
215
+ if (!bundleId)
216
+ return {};
217
+ const res = await run('/usr/bin/defaults', ['export', bundleId, '-']);
218
+ if (res.code !== 0)
219
+ return {};
220
+ let prefs = {};
221
+ try {
222
+ const value = parsePlistXml(res.stdout);
223
+ if (isDict(value))
224
+ prefs = value;
225
+ }
226
+ catch {
227
+ return {};
228
+ }
229
+ const out = {};
230
+ const feed = str(prefs.SUFeedURL);
231
+ if (feed)
232
+ out.sparkleFeedInDefaults = feed;
233
+ const flag = (v) => v === undefined ? undefined : v === true || v === 1 || v === '1' || v === 'YES';
234
+ const checks = flag(prefs.SUEnableAutomaticChecks);
235
+ const auto = flag(prefs.SUAutomaticallyUpdate);
236
+ if (checks !== undefined)
237
+ out.sparkleAutomaticChecks = checks;
238
+ if (auto !== undefined)
239
+ out.sparkleAutomaticallyUpdate = auto;
240
+ return out;
241
+ }
211
242
  /** Reads the Info.plist identity of a bundle without scanning it. */
212
243
  export async function readIdentity(appPath) {
213
244
  const info = await readPlistFile(join(appPath, 'Contents/Info.plist'));
@@ -275,6 +306,10 @@ class HostScanner {
275
306
  truncatedFiles = [];
276
307
  domainLists = [];
277
308
  totalLimitHit = false;
309
+ /** Set once the bundle is known to ship Sparkle; widens what counts as an update feed. */
310
+ sparkle = false;
311
+ feeds = new Set();
312
+ encrypted = [];
278
313
  maxFile;
279
314
  maxTotal;
280
315
  constructor(maxFile, maxTotal) {
@@ -309,7 +344,9 @@ class HostScanner {
309
344
  scanBuffer(buf, where, binary) {
310
345
  this.filesScanned++;
311
346
  this.bytesScanned += buf.length;
312
- this.commit(extractHosts(buf.toString(binary ? 'latin1' : 'utf8'), where, new Map(), true), where);
347
+ const text = buf.toString(binary ? 'latin1' : 'utf8');
348
+ extractFeedUrls(text, this.sparkle, this.feeds);
349
+ this.commit(extractHosts(text, where, new Map(), true), where);
313
350
  }
314
351
  async scanFile(abs, size, where, binary) {
315
352
  let fh;
@@ -325,6 +362,12 @@ class HostScanner {
325
362
  if (slice)
326
363
  ({ offset: start, size: length } = slice);
327
364
  }
365
+ if (binary) {
366
+ const head = Buffer.alloc(Math.min(65536, length));
367
+ const { bytesRead } = await fh.read(head, 0, head.length, start);
368
+ if (isEncryptedMachO(head.subarray(0, bytesRead)))
369
+ this.encrypted.push(where);
370
+ }
328
371
  const n = this.budget(length, where);
329
372
  if (n <= 0)
330
373
  return;
@@ -337,7 +380,9 @@ class HostScanner {
337
380
  const buf = Buffer.alloc(len);
338
381
  const { bytesRead } = await fh.read(buf, 0, len, start + pos);
339
382
  this.bytesScanned += Math.min(bytesRead, CHUNK);
340
- extractHosts(buf.toString(binary ? 'latin1' : 'utf8', 0, bytesRead), where, found, true);
383
+ const text = buf.toString(binary ? 'latin1' : 'utf8', 0, bytesRead);
384
+ extractFeedUrls(text, this.sparkle, this.feeds);
385
+ extractHosts(text, where, found, true);
341
386
  if (bytesRead < len)
342
387
  break;
343
388
  }
@@ -443,6 +488,8 @@ export async function scanBundle(inputPath, options = {}) {
443
488
  return item;
444
489
  });
445
490
  // Privileged helpers installed through SMJobBless.
491
+ scanner.sparkle = nestedItems.some((c) => c.kind === 'framework' && c.path.endsWith('/Sparkle.framework'));
492
+ summary.appStoreReceipt = entries.some((e) => e.rel === 'Contents/_MASReceipt');
446
493
  const helperFiles = files.filter((f) => /(^|\/)Contents\/Library\/LaunchServices\/[^/]+$/.test(f.rel));
447
494
  const helperItems = await mapLimit(helperFiles, 4, async (f) => {
448
495
  bundleExecutables.add(f.rel);
@@ -592,6 +639,14 @@ export async function scanBundle(inputPath, options = {}) {
592
639
  for (const list of scanner.domainLists) {
593
640
  warnings.push(`Skipped ${list.count} bare hostnames in ${list.where}: it looks like a bundled domain list, not code.`);
594
641
  }
642
+ if (scanner.encrypted.length) {
643
+ warnings.push(`${scanner.encrypted.length} binar${scanner.encrypted.length === 1 ? 'y is' : 'ies are'} encrypted (an iPhone or iPad app): hostnames inside can't be read (${scanner.encrypted.slice(0, 3).join(', ')}).`);
644
+ }
645
+ const feedsInCode = [...scanner.feeds].filter((u) => u !== summary.sparkleFeedUrl).sort();
646
+ if (feedsInCode.length)
647
+ summary.sparkleFeedsInCode = feedsInCode.slice(0, 10);
648
+ const notarization = options.notarization === false ? undefined : await readNotarization(appPath);
649
+ const local = options.userDefaults ? await readLocalSettings(str(info.CFBundleIdentifier)) : undefined;
595
650
  const hosts = {};
596
651
  for (const host of [...scanner.hosts.keys()].sort())
597
652
  hosts[host] = scanner.hosts.get(host);
@@ -617,6 +672,9 @@ export async function scanBundle(inputPath, options = {}) {
617
672
  backgroundItems,
618
673
  electron,
619
674
  hosts,
675
+ ...(notarization ? { notarization } : {}),
676
+ ...(scanner.encrypted.length ? { encryptedBinaries: [...scanner.encrypted].sort() } : {}),
677
+ ...(local && Object.keys(local).length ? { local } : {}),
620
678
  stats: {
621
679
  scanMs: Date.now() - started,
622
680
  filesScanned: scanner.filesScanned,
@@ -0,0 +1,216 @@
1
+ import { fetchCask } from "./cask.js";
2
+ import { mapLimit } from "./util.js";
3
+ import { TOOL_VERSION } from "./version.js";
4
+ const OLDER_ONLY = /:or_older/;
5
+ /**
6
+ * Keeps the lines of a cask that apply to a current Apple silicon Mac: `on_arm` and
7
+ * `on_<macOS> :or_newer` blocks and top-level stanzas. Drops `on_intel`, blocks for
8
+ * older macOS versions, and blocks such as `livecheck`, `zap` and `uninstall`.
9
+ */
10
+ function linesForThisMac(source) {
11
+ const kept = [];
12
+ const stack = [];
13
+ for (const line of source.split('\n')) {
14
+ const opens = /^\s*(\S+)(.*?)\bdo(\s*\|[^|]*\|)?\s*$/.exec(line);
15
+ if (opens) {
16
+ const name = opens[1];
17
+ const args = opens[2] ?? '';
18
+ let keep;
19
+ if (name === 'cask')
20
+ keep = true;
21
+ else if (name === 'on_arm')
22
+ keep = true;
23
+ else if (name === 'on_intel')
24
+ keep = false;
25
+ else if (/^on_[a-z_]+$/.test(name) && name !== 'on_arm')
26
+ keep = /:or_newer/.test(args) && !OLDER_ONLY.test(args);
27
+ else
28
+ keep = false;
29
+ stack.push(keep);
30
+ continue;
31
+ }
32
+ if (/^\s*end\s*$/.test(line)) {
33
+ stack.pop();
34
+ continue;
35
+ }
36
+ if (stack.every(Boolean))
37
+ kept.push(line);
38
+ }
39
+ return kept;
40
+ }
41
+ /** The last `version` stanza outside livecheck, for casks that only set it inside OS blocks. */
42
+ function lastVersionAnywhere(source) {
43
+ const withoutLivecheck = source.replace(/\n\s*livecheck do[\s\S]*?\n\s*end\b/g, '\n');
44
+ const all = [...withoutLivecheck.matchAll(/^\s*version\s+"([^"]+)"/gm)];
45
+ return all.at(-1)?.[1];
46
+ }
47
+ /** Reads version, URL template, checksum and arch from a cask's Ruby source. */
48
+ export function parseCaskSource(source) {
49
+ const text = linesForThisMac(source).join('\n');
50
+ const version = /^\s*version\s+"([^"]+)"/m.exec(text)?.[1] ?? lastVersionAnywhere(source);
51
+ const url = /^\s*url\s+"([^"]+)"/m.exec(text)?.[1];
52
+ const shaArm = /sha256\s+arm:\s*"([0-9a-f]{64})"/i.exec(text)?.[1];
53
+ const shaOne = /^\s*sha256\s+"([0-9a-f]{64})"/im.exec(text)?.[1];
54
+ const arch = /^\s*arch\s+arm:\s*"([^"]*)"/m.exec(source)?.[1];
55
+ return { version, url, sha256: (shaArm ?? shaOne)?.toLowerCase() ?? null, arch };
56
+ }
57
+ /** Applies a chain of Homebrew version methods: `version.csv.first.no_dots`. */
58
+ export function applyVersionMethods(version, chain) {
59
+ let value = version;
60
+ for (const method of chain) {
61
+ const s = Array.isArray(value) ? undefined : value;
62
+ switch (method) {
63
+ case 'csv':
64
+ if (s === undefined)
65
+ return undefined;
66
+ value = s.split(',');
67
+ break;
68
+ case 'first':
69
+ case 'second':
70
+ case 'third': {
71
+ if (!Array.isArray(value))
72
+ return undefined;
73
+ const item = value[['first', 'second', 'third'].indexOf(method)];
74
+ if (item === undefined)
75
+ return undefined;
76
+ value = item;
77
+ break;
78
+ }
79
+ case 'before_comma':
80
+ if (s === undefined)
81
+ return undefined;
82
+ value = s.split(',')[0];
83
+ break;
84
+ case 'after_comma': {
85
+ const after = s?.split(',')[1];
86
+ if (after === undefined)
87
+ return undefined;
88
+ value = after;
89
+ break;
90
+ }
91
+ case 'major':
92
+ case 'minor':
93
+ case 'patch': {
94
+ const part = s?.split(/[.,-]/)[['major', 'minor', 'patch'].indexOf(method)];
95
+ if (part === undefined)
96
+ return undefined;
97
+ value = part;
98
+ break;
99
+ }
100
+ case 'major_minor':
101
+ case 'major_minor_patch': {
102
+ if (s === undefined)
103
+ return undefined;
104
+ value = s.split('.').slice(0, method === 'major_minor' ? 2 : 3).join('.');
105
+ break;
106
+ }
107
+ case 'no_dots':
108
+ if (s === undefined)
109
+ return undefined;
110
+ value = s.replace(/\./g, '');
111
+ break;
112
+ case 'dots_to_underscores':
113
+ if (s === undefined)
114
+ return undefined;
115
+ value = s.replace(/\./g, '_');
116
+ break;
117
+ case 'dots_to_hyphens':
118
+ if (s === undefined)
119
+ return undefined;
120
+ value = s.replace(/\./g, '-');
121
+ break;
122
+ case 'no_hyphens':
123
+ if (s === undefined)
124
+ return undefined;
125
+ value = s.replace(/-/g, '');
126
+ break;
127
+ default:
128
+ return undefined;
129
+ }
130
+ }
131
+ return Array.isArray(value) ? undefined : value;
132
+ }
133
+ /** Fills `#{version…}` and `#{arch}` in a cask URL. Undefined if anything is left unresolved. */
134
+ export function resolveCaskUrl(template, version, arch) {
135
+ let unresolved = false;
136
+ const url = template.replace(/#\{([^}]+)\}/g, (_, expr) => {
137
+ const e = expr.trim();
138
+ if (e === 'arch') {
139
+ if (arch === undefined)
140
+ unresolved = true;
141
+ return arch ?? '';
142
+ }
143
+ const parts = e.split('.');
144
+ if (parts[0] !== 'version') {
145
+ unresolved = true;
146
+ return '';
147
+ }
148
+ const value = applyVersionMethods(version, parts.slice(1));
149
+ if (value === undefined)
150
+ unresolved = true;
151
+ return value ?? '';
152
+ });
153
+ return unresolved ? undefined : url;
154
+ }
155
+ export function githubApiBase() {
156
+ return (process.env.UPDIFF_GITHUB_API ?? 'https://api.github.com').replace(/\/$/, '');
157
+ }
158
+ export function githubRawBase() {
159
+ return (process.env.UPDIFF_GITHUB_RAW ?? 'https://raw.githubusercontent.com').replace(/\/$/, '');
160
+ }
161
+ function githubHeaders() {
162
+ const headers = { 'user-agent': `updiff/${TOOL_VERSION}`, accept: 'application/vnd.github+json' };
163
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
164
+ if (token)
165
+ headers.authorization = `Bearer ${token}`;
166
+ return headers;
167
+ }
168
+ /**
169
+ * Past releases of a cask, newest first: walks the commits that touched the cask
170
+ * file in Homebrew/homebrew-cask and reads the cask at each one.
171
+ */
172
+ export async function caskHistory(token, options = {}) {
173
+ const info = options.info ?? (await fetchCask(token));
174
+ if (!info.sourcePath)
175
+ throw new Error(`The Homebrew API did not say where the ${token} cask lives.`);
176
+ const perPage = Math.min(Math.max(options.commits ?? 60, 1), 100);
177
+ const res = await fetch(`${githubApiBase()}/repos/Homebrew/homebrew-cask/commits?path=${encodeURIComponent(info.sourcePath)}&per_page=${perPage}`, { headers: githubHeaders(), signal: AbortSignal.timeout(30_000) });
178
+ if (res.status === 403 || res.status === 429) {
179
+ throw new Error('GitHub is rate-limiting history lookups (60 an hour without a token). Set GITHUB_TOKEN to raise the limit, or try again later.');
180
+ }
181
+ if (!res.ok)
182
+ throw new Error(`GitHub answered HTTP ${res.status} for the ${token} cask's history.`);
183
+ const commits = (await res.json());
184
+ const parsed = await mapLimit(commits, 6, async (c) => {
185
+ const raw = await fetch(`${githubRawBase()}/Homebrew/homebrew-cask/${c.sha}/${info.sourcePath}`, {
186
+ headers: { 'user-agent': `updiff/${TOOL_VERSION}` },
187
+ signal: AbortSignal.timeout(30_000),
188
+ });
189
+ // A commit can delete or move the file; skip it.
190
+ if (!raw.ok)
191
+ return undefined;
192
+ const cask = parseCaskSource(await raw.text());
193
+ if (!cask.version || cask.version === 'latest')
194
+ return undefined;
195
+ const release = {
196
+ version: cask.version,
197
+ sha256: cask.sha256,
198
+ commit: c.sha,
199
+ date: c.commit.committer?.date ?? c.commit.author?.date ?? '',
200
+ };
201
+ const url = cask.url ? resolveCaskUrl(cask.url, cask.version, cask.arch) : undefined;
202
+ if (url)
203
+ release.url = url;
204
+ return release;
205
+ });
206
+ // Commits come newest first; keep the newest commit for each version.
207
+ const releases = [];
208
+ const seen = new Set();
209
+ for (const r of parsed) {
210
+ if (!r || seen.has(r.version))
211
+ continue;
212
+ seen.add(r.version);
213
+ releases.push(r);
214
+ }
215
+ return { info, releases };
216
+ }
package/dist/cask.js ADDED
@@ -0,0 +1,79 @@
1
+ export function caskApiBase() {
2
+ return (process.env.UPDIFF_CASK_API ?? process.env.LEDGER_CASK_API ?? 'https://formulae.brew.sh/api/cask').replace(/\/$/, '');
3
+ }
4
+ function firstStrings(artifacts, key) {
5
+ const out = [];
6
+ for (const a of artifacts) {
7
+ const v = a[key];
8
+ if (!Array.isArray(v))
9
+ continue;
10
+ // Each entry is ["Name.app"] or ["Name.app", { target: "..." }].
11
+ for (const item of v) {
12
+ if (typeof item === 'string')
13
+ out.push(item);
14
+ else if (Array.isArray(item) && typeof item[0] === 'string')
15
+ out.push(item[0]);
16
+ }
17
+ }
18
+ return out;
19
+ }
20
+ /** Turns a cask API document into the fields the ledger needs. */
21
+ export function parseCask(json) {
22
+ const j = json;
23
+ if (typeof j?.token !== 'string' || typeof j.version !== 'string' || typeof j.url !== 'string') {
24
+ throw new Error('Not a Homebrew cask document');
25
+ }
26
+ const artifacts = Array.isArray(j.artifacts) ? j.artifacts : [];
27
+ const names = Array.isArray(j.name) ? j.name : [];
28
+ const sha = typeof j.sha256 === 'string' && /^[0-9a-f]{64}$/i.test(j.sha256) ? j.sha256.toLowerCase() : null;
29
+ return {
30
+ token: j.token,
31
+ name: typeof names[0] === 'string' ? names[0] : j.token,
32
+ desc: typeof j.desc === 'string' ? j.desc : undefined,
33
+ homepage: typeof j.homepage === 'string' ? j.homepage : undefined,
34
+ version: j.version,
35
+ url: j.url,
36
+ sha256: sha,
37
+ appNames: firstStrings(artifacts, 'app'),
38
+ pkgNames: firstStrings(artifacts, 'pkg'),
39
+ sourcePath: typeof j.ruby_source_path === 'string' ? j.ruby_source_path : undefined,
40
+ };
41
+ }
42
+ export async function fetchCask(token) {
43
+ if (!/^[a-z0-9@._+-]+$/.test(token))
44
+ throw new Error(`Invalid cask token "${token}"`);
45
+ const res = await fetch(`${caskApiBase()}/${token}.json`, { signal: AbortSignal.timeout(30_000) });
46
+ if (!res.ok)
47
+ throw new Error(`Cask "${token}": HTTP ${res.status} from the Homebrew API`);
48
+ return parseCask(await res.json());
49
+ }
50
+ /** The forms a version can take inside a download URL. */
51
+ function versionForms(version) {
52
+ const forms = [version, version.replace(/\./g, '_'), version.replace(/\./g, '-')];
53
+ return [...new Set(forms)];
54
+ }
55
+ /**
56
+ * Best-effort URL of another release: substitutes `version` for the cask's current
57
+ * version (and each part of comma-separated versions such as "1.2.3,456") in the
58
+ * current download URL. Returns undefined when the URL does not contain the version.
59
+ */
60
+ export function urlForVersion(info, version) {
61
+ const currentParts = info.version.split(',');
62
+ const nextParts = version.split(',');
63
+ if (currentParts.length !== nextParts.length)
64
+ return undefined;
65
+ let url = info.url;
66
+ let replaced = false;
67
+ for (let i = 0; i < currentParts.length; i++) {
68
+ const from = versionForms(currentParts[i]);
69
+ const to = versionForms(nextParts[i]);
70
+ for (let f = 0; f < from.length; f++) {
71
+ if (from[f] && url.includes(from[f])) {
72
+ url = url.split(from[f]).join(to[f]);
73
+ replaced = true;
74
+ break;
75
+ }
76
+ }
77
+ }
78
+ return replaced ? url : undefined;
79
+ }
package/dist/codesign.js CHANGED
@@ -60,3 +60,44 @@ export async function readEntitlements(path) {
60
60
  return {};
61
61
  }
62
62
  }
63
+ /**
64
+ * Parses `spctl -a -vvv -t exec` output:
65
+ * /Applications/X.app: accepted
66
+ * source=Notarized Developer ID
67
+ * origin=Developer ID Application: Example Inc (TEAMID)
68
+ */
69
+ export function parseSpctl(output) {
70
+ const verdict = /:\s*(accepted|rejected)\s*$/m.exec(output)?.[1];
71
+ const source = /^source=(.+)$/m.exec(output)?.[1]?.trim();
72
+ const origin = /^origin=(.+)$/m.exec(output)?.[1]?.trim();
73
+ const assessment = verdict === 'accepted' || verdict === 'rejected' ? verdict : 'unknown';
74
+ // Without a verdict spctl prints "<path>: <reason>", e.g. a broken seal.
75
+ const error = assessment === 'unknown' ? /^.*?:\s*(.+)$/m.exec(output.trim())?.[1]?.trim() : undefined;
76
+ return {
77
+ assessment,
78
+ ...(source ? { source } : {}),
79
+ ...(origin ? { origin } : {}),
80
+ ...(error ? { error } : {}),
81
+ };
82
+ }
83
+ /**
84
+ * Reads `stapler validate` by its message: its exit status is 0 whether or not a
85
+ * ticket is stapled. Undefined when stapler isn't available (no Xcode tools).
86
+ */
87
+ export function parseStapler(res) {
88
+ const text = `${res.stdout}\n${res.stderr}`;
89
+ if (/The validate action worked/i.test(text))
90
+ return true;
91
+ if (/does not have a ticket stapled/i.test(text))
92
+ return false;
93
+ return undefined;
94
+ }
95
+ /** Asks Gatekeeper about a bundle and checks for a stapled notarization ticket. */
96
+ export async function readNotarization(path) {
97
+ const spctl = await run('/usr/sbin/spctl', ['-a', '-vvv', '-t', 'exec', path], 120_000);
98
+ const info = parseSpctl(`${spctl.stdout}\n${spctl.stderr}`);
99
+ const stapled = parseStapler((await run('/usr/bin/xcrun', ['stapler', 'validate', path], 60_000)));
100
+ if (stapled !== undefined)
101
+ info.stapled = stapled;
102
+ return info;
103
+ }