103 lines
7 KiB
TypeScript
103 lines
7 KiB
TypeScript
// 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 }[] {
|
|
// the checkout is a needle only when it says something about the person: inside the home folder or naming the user.
|
|
// A shared build folder (/Users/Shared/..., /opt/...) recorded by dsymutil and Xcode's module cache is not private
|
|
const root = trim(i.root) + '/';
|
|
const personal = root.startsWith(trim(i.home) + '/') || (i.user.length >= 3 && root.includes(`/${i.user}/`));
|
|
const list = [{ kind: 'home folder', value: trim(i.home) + '/' }, { kind: 'user folder', value: `/Users/${i.user}/` }, ...(personal ? [{ kind: 'checkout', value: 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).';
|
|
}
|