apple build: --derived-data (default <out>/DerivedData with --out), --path-free (Swift/clang prefix maps, no serialized debug options) and an archive path scan

This commit is contained in:
CRTkafa 2026-10-26 19:12:21 +07:00
parent cb20912f0e
commit f109c519ae
10 changed files with 276 additions and 7 deletions

99
cli/lib/path-free.ts Normal file
View file

@ -0,0 +1,99 @@
// Path-free iOS builds: no home folder, user name or checkout path inside the app binary or the archive's debug
// symbols (a privacy gate over a shared release folder found hundreds of hits). Used by `apple build --path-free`.
// - derivedDataFor: DerivedData per output folder (hermetic), not ~/Library/Developer/Xcode/DerivedData
// - pathFreeSettings: xcodebuild build settings with Swift / clang prefix maps (dSYM and Symbols stay, paths are remapped)
// - scanArchiveLeaks / leakProblem: scan the archive's .app, dSYMs and Symbols for the home folder and the user name
// Pure except scanArchiveLeaks (bounded file reads).
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { posix } from 'node:path';
const trim = (p: string): string => (p.length > 1 ? p.replace(/\/+$/, '') : p);
/** DerivedData folder of a build: --derived-data, else apple.build.derivedData, else <out>/DerivedData when the output
* folder was chosen (--out / apple.build.out), else null (Xcode's default under ~/Library). */
export function derivedDataFor(o: { flag?: string; config?: string; out: string; outChosen: boolean; resolve: (p: string) => string }): string | null {
const given = o.flag ?? o.config;
if (given) return o.resolve(given);
return o.outChosen ? posix.join(o.out, 'DerivedData') : null;
}
/** True when `dir` is not inside `home`. */
export const outsideHome = (dir: string, home: string): boolean => { const h = trim(home); return !!h && dir !== h && !dir.startsWith(`${h}/`); };
export interface PathFreeInput { home: string; root: string; out: string; derivedData?: string | null }
/** [from, to] prefix maps: the home folder, the checkout, the output folder and DerivedData, longest (most specific) first.
* swiftc applies the FIRST matching map, clang the LAST: pathFreeSettings reverses the list for clang. */
export function prefixMaps(i: PathFreeInput): [string, string][] {
const all: [string, string][] = [[trim(i.home), '/home'], [trim(i.root), '.'], [trim(i.out), '/out']];
if (i.derivedData) all.push([trim(i.derivedData), '/derived']);
const seen = new Set<string>();
return all.filter(([from]) => from.length > 1 && !seen.has(from) && seen.add(from)).sort((a, b) => b[0].length - a[0].length);
}
const q = (s: string): string => (/[\s"'$]/.test(s) ? `"${s.replace(/(["\$])/g, '\$1')}"` : s);
/** xcodebuild build settings that remap every local path in Swift, C, C++ and Objective-C output and in DWARF. The
* debug format is left alone (dwarf-with-dsym), so symbol upload keeps working. */
export function pathFreeSettings(i: PathFreeInput): string[] {
const maps = prefixMaps(i);
const swift = maps.flatMap(([f, t]) => ['-debug-prefix-map', q(`${f}=${t}`), '-file-prefix-map', q(`${f}=${t}`)]);
const clang = [...maps].reverse().flatMap(([f, t]) => [q(`-ffile-prefix-map=${f}=${t}`)]);
// SWIFT_SERIALIZE_DEBUGGING_OPTIONS=NO: Xcode embeds the compiler's include paths, module cache and -ffile-compilation-dir
// (the checkout) in the binary's __swift_ast section; no prefix map reaches it. Only lldb's Swift expression evaluation
// uses it; crash symbolication (the dSYM's functions and lines) does not.
return ['SWIFT_SERIALIZE_DEBUGGING_OPTIONS=NO', `OTHER_SWIFT_FLAGS=$(inherited) ${swift.join(' ')}`, `OTHER_CFLAGS=$(inherited) ${clang.join(' ')}`, `OTHER_CPLUSPLUSFLAGS=$(inherited) ${clang.join(' ')}`];
}
export interface PathHit { file: string; kind: string; offset: number }
/** The needles of a leak scan, named by kind so a report never prints the value: the home folder, /Users/<user>/ and the
* checkout. DerivedData is no needle: outside the home folder it is not the user's (dsymutil and Xcode record it in the
* dSYM and the __swift_ast section and no prefix map reaches it); inside the home folder the home needle finds it. */
export function leakNeedles(i: { home: string; user: string; root: string }): { kind: string; value: string }[] {
const list = [{ kind: 'home folder', value: trim(i.home) + '/' }, { kind: 'user folder', value: `/Users/${i.user}/` }, { kind: 'checkout', value: trim(i.root) + '/' }];
const seen = new Set<string>();
return list.filter(n => n.value.length >= 6 && !seen.has(n.value) && seen.add(n.value));
}
const MAX_FILE = 600 * 1048576;
/** Every needle occurrence in the files under `dirs` (all of them are counted; the first `perFile` offsets per file kept). */
export function scanForPaths(dirs: string[], needles: { kind: string; value: string }[], perFile = 3): { hits: PathHit[]; total: number; files: number } {
const ns = needles.map(n => ({ kind: n.kind, b: Buffer.from(n.value) }));
const hits: PathHit[] = [];
const bad = new Set<string>();
let total = 0;
const walk = (base: string, d: string): void => {
let names: string[]; try { names = readdirSync(d); } catch { return; }
for (const name of names) {
const p = join(d, name);
let st; try { st = statSync(p); } catch { continue; }
if (st.isDirectory()) { walk(base, p); continue; }
if (!st.isFile() || st.size > MAX_FILE) continue;
const buf = readFileSync(p);
let kept = 0;
for (const { kind, b } of ns) {
for (let at = buf.indexOf(b); at >= 0; at = buf.indexOf(b, at + b.length)) {
total++; bad.add(p);
if (kept++ < perFile) hits.push({ file: relative(base, p).split(sep).join('/'), kind, offset: at });
}
}
}
};
for (const d of dirs) if (existsSync(d)) walk(d, d);
return { hits, total, files: bad.size };
}
/** What the archive holds that ships or symbolicates: Products (the .app, extensions, frameworks), dSYMs, Symbols. */
export const archiveScanDirs = (archive: string): string[] => ['Products', 'dSYMs', 'Symbols'].map(x => join(archive, x));
/** The refusal text of a path-free build that still has local paths, else null. Counts, kinds, file and offset only. */
export function leakProblem(r: { hits: PathHit[]; total: number; files: number }, archive: string): string | null {
if (!r.total) return null;
const lines = r.hits.slice(0, 8).map(h => ` ${h.file} @${h.offset}: ${h.kind}`);
return `${r.total} local path${r.total === 1 ? '' : 's'} left in ${r.files} file${r.files === 1 ? '' : 's'} of ${archive} (home folder, user name or checkout):\n${lines.join('\n')}${r.hits.length > 8 ? '\n ...' : ''}\n`
+ 'Stopped before the export. Causes: #file / #filePath in Swift sources (no prefix map reaches those literals: use #fileID), a prebuilt framework or Pod that carries its own paths '
+ '(rebuild it path-free), or DerivedData / --out inside the home folder (dsymutil and Xcode record that path: use --out or --derived-data outside it, e.g. a shared folder).';
}

View file

@ -18,6 +18,10 @@ export interface AppleBuildConfig {
configuration?: string;
/** Output folder for the archive, ExportOptions.plist, the IPA and logs. Default build/ios. */
out?: string;
/** xcodebuild -derivedDataPath. Default <out>/DerivedData when the output folder is chosen (--out / out), else Xcode's own folder under ~/Library. */
derivedData?: string;
/** Remap the home folder, checkout, output folder and DerivedData out of the binary and the debug symbols, then scan the archive (same as --path-free). */
pathFree?: boolean;
}
export function validateAppleBuild(b: AppleBuildConfig | undefined): string[] {
@ -26,6 +30,8 @@ export function validateAppleBuild(b: AppleBuildConfig | undefined): string[] {
if (b.workspace && !b.workspace.endsWith('.xcworkspace')) e.push('apple.build.workspace: a path ending in .xcworkspace');
if (b.project && !b.project.endsWith('.xcodeproj')) e.push('apple.build.project: a path ending in .xcodeproj');
if (b.scheme !== undefined && !b.scheme.trim()) e.push('apple.build.scheme: empty');
if (b.derivedData !== undefined && (typeof b.derivedData !== 'string' || !b.derivedData.trim())) e.push('apple.build.derivedData: a folder path');
if (b.pathFree !== undefined && typeof b.pathFree !== 'boolean') e.push('apple.build.pathFree: true or false');
return e;
}
@ -125,13 +131,13 @@ export interface AscAuth { keyPath: string; keyId: string; issuerId: string }
const authArgs = (a: AscAuth | null): string[] => (a ? ['-allowProvisioningUpdates', '-authenticationKeyPath', a.keyPath, '-authenticationKeyID', a.keyId, '-authenticationKeyIssuerID', a.issuerId] : []);
export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null; /** extra build settings, e.g. OTHER_CODE_SIGN_FLAGS=--keychain <path> */ settings?: string[] }
export interface ArchiveInput { target: XcodeTarget; root: string; configuration: string; archivePath: string; version?: string; build: string; teamId?: string; auth: AscAuth | null; /** extra build settings, e.g. OTHER_CODE_SIGN_FLAGS=--keychain <path> */ settings?: string[]; /** xcodebuild -derivedDataPath */ derivedData?: string | null }
/** xcodebuild archive. Version / build go in as build settings (they beat the project's values). */
export function archiveArgs(i: ArchiveInput): string[] {
return ['xcodebuild', ...(i.target.workspace ? ['-workspace', join(i.root, i.target.workspace)] : ['-project', join(i.root, i.target.project!)]),
'-scheme', i.target.scheme, '-configuration', i.configuration, '-destination', 'generic/platform=iOS', '-archivePath', i.archivePath,
...authArgs(i.auth), 'archive', `CURRENT_PROJECT_VERSION=${i.build}`, ...(i.version ? [`MARKETING_VERSION=${i.version}`] : []),
...(i.derivedData ? ['-derivedDataPath', i.derivedData] : []), ...authArgs(i.auth), 'archive', `CURRENT_PROJECT_VERSION=${i.build}`, ...(i.version ? [`MARKETING_VERSION=${i.version}`] : []),
...(i.teamId ? [`DEVELOPMENT_TEAM=${i.teamId}`] : []), ...(i.settings ?? [])];
}