// Mac App Store entitlements of a Tauri build (cli/lib/mas.ts, cli/apple/build-mas.ts): the overlay config // (tauri..conf.json) merged over tauri.conf.json, the source order of the entitlements plist, and the check of // Developer ID / development keys that do not belong in a Mac App Store build. import { afterAll, describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { run as buildMas } from '../cli/apple/build-mas'; import type { ShipConfig } from '../cli/lib/config'; import { appProblems, entitlementsSource, entitlementsXml, findTauri, masEntitlementProblems, masSteps, mergePatch, tauriOverlay } from '../cli/lib/mas'; import { BASE, capture, project } from './helpers/store-mock'; const tmp = mkdtempSync(join(tmpdir(), 'sb-masent-')); afterAll(() => rmSync(tmp, { recursive: true, force: true })); const BASE_CONF = { productName: 'Demo App', identifier: 'com.example.notes', version: '1.2.3', bundle: { category: 'Utility', macOS: { entitlements: 'Entitlements.plist', minimumSystemVersion: '12.0' } } }; const OVERLAY = { bundle: { macOS: { entitlements: 'Entitlements.appstore.plist' } } }; const DEVID = { 'com.apple.security.cs.disable-library-validation': true, 'com.apple.security.cs.allow-jit': true, 'com.apple.security.network.client': true }; const APPSTORE = { 'com.apple.security.files.user-selected.read-only': true, 'com.apple.security.network.client': true }; function fixture(files: Record = {}): { root: string; dir: string } { const root = mkdtempSync(join(tmp, 'p-')); const dir = join(root, 'src-tauri'); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'tauri.conf.json'), JSON.stringify(BASE_CONF)); for (const [n, v] of Object.entries(files)) writeFileSync(join(dir, n), typeof v === 'string' ? v : JSON.stringify(v)); return { root, dir }; } describe('merge patch', () => { test('objects merge, null removes, arrays and scalars replace', () => { expect(mergePatch({ a: { b: 1, c: 2 }, d: [1, 2], e: 1 }, { a: { b: 9 }, d: [3], e: null })).toEqual({ a: { b: 9, c: 2 }, d: [3] }); expect(mergePatch({ a: 1 }, { a: { x: 1 } })).toEqual({ a: { x: 1 } }); expect(mergePatch('x', 5)).toBe(5); }); }); describe('overlay config', () => { test('an overlay file path is merged over the base config in its folder', () => { const { root, dir } = fixture({ 'tauri.appstore.conf.json': OVERLAY }); const t = findTauri(root, 'src-tauri/tauri.appstore.conf.json'); expect(t.overlayPath).toBe(join(dir, 'tauri.appstore.conf.json')); expect(t.overlayAuto).toBe(false); expect(t.entitlements).toBe(join(dir, 'Entitlements.appstore.plist')); expect(t.productName).toBe('Demo App'); expect(t.minOs).toBe('12.0'); }); test('a folder picks up tauri.appstore.conf.json, then tauri.mas.conf.json, on its own; a scan does too', () => { const a = fixture({ 'tauri.appstore.conf.json': OVERLAY }); expect(findTauri(a.root, 'src-tauri')).toMatchObject({ overlayPath: join(a.dir, 'tauri.appstore.conf.json'), overlayAuto: true }); expect(findTauri(a.root).overlayAuto).toBe(true); const m = fixture({ 'tauri.mas.conf.json': OVERLAY }); expect(findTauri(m.root, 'src-tauri').overlayPath).toBe(join(m.dir, 'tauri.mas.conf.json')); const both = fixture({ 'tauri.appstore.conf.json': OVERLAY, 'tauri.mas.conf.json': { bundle: { macOS: { entitlements: 'other.plist' } } } }); expect(findTauri(both.root, 'src-tauri').entitlements).toBe(join(both.dir, 'Entitlements.appstore.plist')); }); test('naming the base file directly still auto-detects; no overlay means none', () => { const { root, dir } = fixture({ 'tauri.appstore.conf.json': OVERLAY }); expect(findTauri(root, 'src-tauri/tauri.conf.json').entitlements).toBe(join(dir, 'Entitlements.appstore.plist')); const plain = fixture(); const t = findTauri(plain.root, 'src-tauri'); expect(t.overlayPath).toBeUndefined(); expect(t.entitlements).toBe(join(plain.dir, 'Entitlements.plist')); }); test('an overlay without a base config, a missing file, bad JSON', () => { const root = mkdtempSync(join(tmp, 'o-')); mkdirSync(join(root, 'x'), { recursive: true }); writeFileSync(join(root, 'x/tauri.appstore.conf.json'), '{}'); expect(() => findTauri(root, 'x/tauri.appstore.conf.json')).toThrow('no tauri.conf.json next to this overlay'); const { root: r2 } = fixture(); expect(() => findTauri(r2, 'src-tauri/tauri.nope.conf.json')).toThrow('no such file'); const bad = fixture({ 'tauri.appstore.conf.json': '{ nope' }); expect(() => findTauri(bad.root, 'src-tauri')).toThrow('not valid JSON'); }); test('tauri build gets the overlay first and storebound\'s own overlay last', () => { const { root, dir } = fixture({ 'tauri.appstore.conf.json': OVERLAY }); const tauri = findTauri(root, 'src-tauri'); const input = { tauri, tauriCmd: ['tauri'], target: 'universal-apple-darwin', out: join(root, 'out'), configFile: join(root, 'out/tauri.mas.conf.json'), entitlementsFile: 'e', profile: null, appIdentity: null, installerIdentity: null, version: '1.0.0', build: '1', unsigned: true, pathFree: false, pkg: 'x.pkg' }; const argv = masSteps(input).tauri; const cfgs = argv.flatMap((x, i) => (x === '--config' ? [argv[i + 1]] : [])); expect(cfgs).toEqual([join(dir, 'tauri.appstore.conf.json'), input.configFile]); const plain = masSteps({ ...input, tauri: findTauri(fixture().root, 'src-tauri') }).tauri; expect(plain.filter(x => x === '--config').length).toBe(1); expect(tauriOverlay({ appIdentity: 'id', entitlementsFile: 'e', profile: null, version: '1', build: '1', unsigned: false })).toBeTruthy(); }); }); describe('entitlements source order', () => { test('flag, then config, then the Tauri config', () => { expect(entitlementsSource({ flag: '/f', config: '/c', tauri: '/t' })).toEqual({ file: '/f', source: '--entitlements' }); expect(entitlementsSource({ config: '/c', tauri: '/t' })).toEqual({ file: '/c', source: 'apple.macos.entitlements' }); expect(entitlementsSource({ tauri: '/t' })).toEqual({ file: '/t', source: 'tauri config bundle.macOS.entitlements' }); expect(entitlementsSource({})).toBeNull(); }); }); describe('entitlement key check', () => { test('refused keys are errors, hardened-runtime exceptions warnings, false values and fine keys pass', () => { const r = masEntitlementProblems({ ...DEVID, 'com.apple.security.get-task-allow': true, 'com.apple.security.cs.allow-unsigned-executable-memory': true, 'com.apple.security.cs.disable-executable-page-protection': true, 'com.apple.security.cs.allow-dyld-environment-variables': true, 'com.apple.security.automation.apple-events': true, 'com.apple.security.cs.debugger': false, 'com.apple.security.files.user-selected.read-only': true, }); expect(r.errors.map(e => e.split(' ')[0])).toEqual(['com.apple.security.cs.disable-library-validation', 'com.apple.security.get-task-allow']); expect(r.warnings.map(e => e.split(' ')[0]).sort()).toEqual([ 'com.apple.security.automation.apple-events', 'com.apple.security.cs.allow-dyld-environment-variables', 'com.apple.security.cs.allow-jit', 'com.apple.security.cs.allow-unsigned-executable-memory', 'com.apple.security.cs.disable-executable-page-protection', ]); expect(masEntitlementProblems(APPSTORE)).toEqual({ errors: [], warnings: [] }); expect(masEntitlementProblems(null)).toEqual({ errors: [], warnings: [] }); }); test('the built app is checked too; --allow-entitlements turns the errors into warnings', () => { const facts = { info: { CFBundleIdentifier: 'com.example.notes', CFBundleShortVersionString: '1.0.0', CFBundleVersion: '5', LSApplicationCategoryType: 'public.app-category.utilities', LSMinimumSystemVersion: '12.0', ITSAppUsesNonExemptEncryption: false }, entitlements: { 'com.apple.security.app-sandbox': true, 'com.apple.application-identifier': 'T.com.example.notes', 'com.apple.developer.team-identifier': 'T', ...DEVID }, hasProfile: true, leaks: [], }; const o = { bundleId: 'com.example.notes', version: '1.0.0', build: '5', signed: true, pathFree: false }; const r = appProblems(facts, o); expect(r.errors.join()).toContain('disable-library-validation'); expect(r.warnings.join()).toContain('allow-jit'); const allowed = appProblems(facts, { ...o, allowEntitlements: true }); expect(allowed.errors).toEqual([]); expect(allowed.warnings.join()).toContain('disable-library-validation'); }); }); // the whole command, as a dry run (no store, no Mac) describe('apple build --platform macos --dry', () => { const CONFIG: ShipConfig = { ...BASE }; const files = (extra: Record = {}): Record => ({ 'src-tauri/tauri.conf.json': JSON.stringify(BASE_CONF), 'src-tauri/tauri.appstore.conf.json': JSON.stringify(OVERLAY), 'src-tauri/Entitlements.plist': entitlementsXml(DEVID), 'src-tauri/Entitlements.appstore.plist': entitlementsXml(APPSTORE), ...extra, }); const dry = async (cfg: ShipConfig, f: Record, argv: string[]): Promise<{ plan: any; err: string }> => { const p = project(cfg, f); const r = await capture(() => buildMas(p.ctx(['--tauri', 'src-tauri', '--unsigned', '--build-number', '7', '--dry', '--json', ...argv]), 'linux')); return { plan: JSON.parse(r.out.slice(r.out.indexOf('{'))), err: r.err }; }; test('the App Store overlay is found and its plist used, not the Developer ID one', async () => { const { plan } = await dry(CONFIG, files(), []); expect(String(plan.tauriOverlayFile).replaceAll("\\", "/")).toBe('src-tauri/tauri.appstore.conf.json'); expect({ ...plan.entitlementsSource, file: plan.entitlementsSource.file.replaceAll("\\", "/") }).toEqual({ source: 'tauri config bundle.macOS.entitlements', file: 'src-tauri/Entitlements.appstore.plist' }); expect(plan.entitlements).toContain('com.apple.security.files.user-selected.read-only'); expect(plan.entitlements).not.toContain('com.apple.security.cs.disable-library-validation'); expect(plan.entitlementProblems).toEqual({ errors: [], warnings: [] }); expect(plan.steps[0]).toContain('tauri.appstore.conf.json'); }); test('the Developer ID plist is reported (error under a real build, a warning here)', async () => { const f = files(); delete (f as Record)['src-tauri/tauri.appstore.conf.json']; const { plan, err } = await dry(CONFIG, f, []); expect(plan.entitlementsSource.file.replaceAll("\\", "/")).toBe('src-tauri/Entitlements.plist'); expect(plan.entitlementProblems.errors.join()).toContain('disable-library-validation'); expect(plan.entitlementProblems.warnings.join()).toContain('allow-jit'); expect(err).toBe(''); // --json prints only the plan const pj = project(CONFIG, f); const text = await capture(() => buildMas(pj.ctx(['--tauri', 'src-tauri', '--unsigned', '--build-number', '7', '--dry']), 'linux')); expect(text.err).toContain('disable-library-validation'); expect(text.err).toContain('--allow-entitlements'); expect(text.out).toContain('entitlements from tauri config'); }); test('--entitlements beats apple.macos.entitlements beats the Tauri config', async () => { const f = files({ 'a.plist': entitlementsXml({ 'com.apple.security.files.downloads.read-only': true }), 'b.plist': entitlementsXml({ 'com.apple.security.files.user-selected.read-write': true }) }); delete (f as Record)['src-tauri/tauri.appstore.conf.json']; const cfg: ShipConfig = { ...CONFIG, apple: { ...CONFIG.apple!, macos: { entitlements: 'a.plist' } } }; const viaConfig = await dry(cfg, f, []); expect(viaConfig.plan.entitlementsSource).toEqual({ source: 'apple.macos.entitlements', file: 'a.plist' }); expect(viaConfig.plan.entitlements).toContain('com.apple.security.files.downloads.read-only'); const viaFlag = await dry(cfg, f, ['--entitlements', 'b.plist']); expect(viaFlag.plan.entitlementsSource).toEqual({ source: '--entitlements', file: 'b.plist' }); expect(viaFlag.plan.entitlements).toContain('com.apple.security.files.user-selected.read-write'); expect(viaFlag.plan.entitlements).not.toContain('com.apple.security.files.downloads.read-only'); }); test('a named plist that does not exist stops the run', async () => { const p = project(CONFIG, files()); await expect(buildMas(p.ctx(['--tauri', 'src-tauri', '--unsigned', '--build-number', '7', '--dry', '--entitlements', 'nope.plist']), 'linux')).rejects.toThrow('does not exist'); }); });