182 lines
12 KiB
TypeScript
182 lines
12 KiB
TypeScript
// Mac App Store entitlements of a Tauri build (cli/lib/mas.ts, cli/apple/build-mas.ts): the overlay config
|
|
// (tauri.<name>.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<string, unknown> = {}): { 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<string, string> = {}): Record<string, string> => ({
|
|
'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<string, string>, 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<string, string | undefined>)['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<string, string | undefined>)['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');
|
|
});
|
|
});
|