@mmerterden/multi-agent-toolkit-mcp 3.9.0 → 3.12.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/CHANGELOG.md +239 -0
- package/README.md +79 -2
- package/README.tr.md +6 -3
- package/index.js +160 -21
- package/package.json +9 -3
- package/tools/code-intel/index.js +665 -0
- package/tools/code-intel/kotlin.js +159 -0
- package/tools/code-intel/lsp-client.js +422 -0
- package/tools/code-intel/pool.js +273 -0
- package/tools/code-intel/positions.js +195 -0
- package/tools/code-intel/swift.js +249 -0
- package/tools/ios-app-store-audit/context.js +160 -9
- package/tools/ios-app-store-audit/index.js +24 -1
- package/tools/offload/index.js +28 -8
- package/tools/pass-kit/index.js +432 -0
- package/tools/pass-kit/sign.js +255 -0
- package/tools/pass-kit/spec.js +317 -0
- package/tools/pass-kit/validate.js +329 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* swift.js - sourcekit-lsp, and an honest account of what it can answer.
|
|
3
|
+
*
|
|
4
|
+
* CAPABILITY IS NOT CAPABILITY. `initialize` returns `referencesProvider: true`
|
|
5
|
+
* whether or not references will ever be non-empty. Measured on this machine:
|
|
6
|
+
* against a package root, `textDocument/references` answered `[]` at 694ms and
|
|
7
|
+
* `[3]` at 5.8s, with nothing changed in between except that the background
|
|
8
|
+
* index had finished. Against a real 658-file package with dependencies it was
|
|
9
|
+
* still `[]` at 70s, having spent that time resolving and fetching. So the
|
|
10
|
+
* useful question is never "does the server say it supports references" but
|
|
11
|
+
* "has the index for this root settled", and that is what `indexReport` answers
|
|
12
|
+
* and what the reference tools wait on.
|
|
13
|
+
*
|
|
14
|
+
* WORKSPACE ROOT decides everything else. sourcekit-lsp takes its compiler
|
|
15
|
+
* arguments from a build system, and which one it finds determines whether the
|
|
16
|
+
* answers are semantic or a guess. The resolution order below is by strength of
|
|
17
|
+
* evidence, and whichever one matched is returned in every result, because a
|
|
18
|
+
* caller reading "0 references" deserves to know it came from a fallback.
|
|
19
|
+
*
|
|
20
|
+
* @module tools/code-intel/swift
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
24
|
+
import { dirname, join, basename } from "node:path";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { execFileSync } from "node:child_process";
|
|
27
|
+
|
|
28
|
+
/** Directories a root walk must not climb into or count. */
|
|
29
|
+
const SKIP_DIRS = new Set([".git", ".build", "build", "DerivedData", "Pods", "node_modules"]);
|
|
30
|
+
|
|
31
|
+
let cachedBin;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Where sourcekit-lsp is.
|
|
35
|
+
*
|
|
36
|
+
* PATH before `xcrun` on purpose: it is what lets a test put a fake server on
|
|
37
|
+
* PATH and have it used, and on a real machine `/usr/bin/sourcekit-lsp` is on
|
|
38
|
+
* PATH anyway. `SOURCEKIT_LSP_PATH` wins over both.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveSwiftBin() {
|
|
41
|
+
if (cachedBin !== undefined) return cachedBin;
|
|
42
|
+
const explicit = process.env.SOURCEKIT_LSP_PATH;
|
|
43
|
+
if (explicit && existsSync(explicit)) return (cachedBin = explicit);
|
|
44
|
+
const onPath = which("sourcekit-lsp");
|
|
45
|
+
if (onPath) return (cachedBin = onPath);
|
|
46
|
+
try {
|
|
47
|
+
const found = execFileSync("xcrun", ["--find", "sourcekit-lsp"], {
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
50
|
+
timeout: 10000,
|
|
51
|
+
}).trim();
|
|
52
|
+
if (found && existsSync(found)) return (cachedBin = found);
|
|
53
|
+
} catch {
|
|
54
|
+
/* no Xcode */
|
|
55
|
+
}
|
|
56
|
+
return (cachedBin = null);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function which(cmd) {
|
|
60
|
+
try {
|
|
61
|
+
const p = execFileSync("/usr/bin/which", [cmd], {
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
64
|
+
timeout: 5000,
|
|
65
|
+
}).trim();
|
|
66
|
+
return p && existsSync(p) ? p : null;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** For tests, which change PATH between cases. */
|
|
73
|
+
export function resetSwiftBinCache() {
|
|
74
|
+
cachedBin = undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The strongest build-settings source at or above `file`.
|
|
79
|
+
*
|
|
80
|
+
* @returns {{root: string, source: string}}
|
|
81
|
+
*/
|
|
82
|
+
export function resolveWorkspaceRoot(file, explicitRoot) {
|
|
83
|
+
if (explicitRoot) return { root: explicitRoot, source: classify(explicitRoot) };
|
|
84
|
+
let dir = statSafe(file)?.isDirectory() ? file : dirname(file);
|
|
85
|
+
const seen = [];
|
|
86
|
+
for (let i = 0; i < 40 && dir && dir !== "/"; i++) {
|
|
87
|
+
seen.push(dir);
|
|
88
|
+
const s = classify(dir);
|
|
89
|
+
if (s !== "none") return { root: dir, source: s };
|
|
90
|
+
dir = dirname(dir);
|
|
91
|
+
}
|
|
92
|
+
const git = gitRoot(file);
|
|
93
|
+
if (git) return { root: git, source: "fallback" };
|
|
94
|
+
return { root: seen[0] || dirname(file), source: "fallback" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function classify(dir) {
|
|
98
|
+
if (existsSync(join(dir, "buildServer.json"))) return "buildServer";
|
|
99
|
+
if (existsSync(join(dir, "Package.swift"))) return "swiftpm";
|
|
100
|
+
if (existsSync(join(dir, "compile_commands.json"))) return "compilationDatabase";
|
|
101
|
+
if (existsSync(join(dir, "compile_flags.txt"))) return "compilationDatabase";
|
|
102
|
+
let entries;
|
|
103
|
+
try {
|
|
104
|
+
entries = readdirSync(dir);
|
|
105
|
+
} catch {
|
|
106
|
+
return "none";
|
|
107
|
+
}
|
|
108
|
+
if (entries.some((e) => e.endsWith(".xcworkspace") || e.endsWith(".xcodeproj"))) return "xcode";
|
|
109
|
+
return "none";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function statSafe(p) {
|
|
113
|
+
try {
|
|
114
|
+
return statSync(p);
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function gitRoot(file) {
|
|
121
|
+
try {
|
|
122
|
+
return execFileSync("git", ["-C", dirname(file), "rev-parse", "--show-toplevel"], {
|
|
123
|
+
encoding: "utf8",
|
|
124
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
125
|
+
timeout: 5000,
|
|
126
|
+
}).trim();
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* What an answer from this root is worth, before any question is asked.
|
|
134
|
+
*
|
|
135
|
+
* `semantic` is the headline: true when cross-file answers can be trusted,
|
|
136
|
+
* false when the server will still reply but from fallback arguments. Nothing
|
|
137
|
+
* here starts a server - it is the tool a caller runs BECAUSE something is
|
|
138
|
+
* missing, so it must never be the thing that fails.
|
|
139
|
+
*/
|
|
140
|
+
export function indexReport(root, source) {
|
|
141
|
+
const spm = [
|
|
142
|
+
join(root, ".build", "index-build"),
|
|
143
|
+
join(root, ".build", "index-store"),
|
|
144
|
+
].filter(existsSync);
|
|
145
|
+
const derived = derivedDataStore(root);
|
|
146
|
+
const built = spm.length > 0 || Boolean(derived);
|
|
147
|
+
|
|
148
|
+
const semantic = source === "swiftpm" || source === "buildServer" || source === "compilationDatabase";
|
|
149
|
+
const report = {
|
|
150
|
+
root,
|
|
151
|
+
buildSettingsSource: source,
|
|
152
|
+
semantic,
|
|
153
|
+
indexStore: derived || spm[0] || null,
|
|
154
|
+
indexBuilt: built,
|
|
155
|
+
remedy: [],
|
|
156
|
+
};
|
|
157
|
+
if (derived) {
|
|
158
|
+
const units = countUnits(join(derived, "v5", "units"));
|
|
159
|
+
if (units) {
|
|
160
|
+
report.unitCount = units.count;
|
|
161
|
+
report.newestUnit = new Date(units.newest).toISOString();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (source === "xcode") {
|
|
165
|
+
report.remedy.push(
|
|
166
|
+
"This root is an Xcode project with no buildServer.json, so sourcekit-lsp falls back to default compiler arguments: cross-module answers and diagnostics will be wrong rather than missing. `brew install xcode-build-server && xcode-build-server config -project <X>.xcodeproj -scheme <S>` fixes it. Nothing here writes that file for you.",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (source === "fallback") {
|
|
170
|
+
report.remedy.push(
|
|
171
|
+
"No build system was found at or above this file. Point --workspace_root at the package or project root.",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (semantic && !built) {
|
|
175
|
+
report.remedy.push(
|
|
176
|
+
"No index on disk yet. The first cross-file question builds one in the background; on a package with dependencies that is minutes, not seconds.",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return report;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Xcode writes its index under DerivedData, in `<name>-<hash>`.
|
|
184
|
+
*
|
|
185
|
+
* `<name>` is the PROJECT's name, not the directory's, and the two differ often
|
|
186
|
+
* enough that assuming they match is the failure mode this whole file exists to
|
|
187
|
+
* avoid. Measured on a checkout whose directory name and `.xcodeproj` name had
|
|
188
|
+
* nothing in common: reported as having no index at all, while an 18,205-unit
|
|
189
|
+
* store sat under the project's name, and the remedy printed underneath told
|
|
190
|
+
* the caller to build one. So the candidate names come from the project and
|
|
191
|
+
* workspace files in the root, with the directory name last.
|
|
192
|
+
*
|
|
193
|
+
* Several folders can share a prefix - the same project checked out twice. The
|
|
194
|
+
* newest store wins, because the question being asked is about freshness.
|
|
195
|
+
*/
|
|
196
|
+
function derivedDataStore(root) {
|
|
197
|
+
const dd = join(homedir(), "Library", "Developer", "Xcode", "DerivedData");
|
|
198
|
+
let entries;
|
|
199
|
+
try {
|
|
200
|
+
entries = readdirSync(dd);
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const names = new Set([basename(root)]);
|
|
205
|
+
try {
|
|
206
|
+
for (const e of readdirSync(root)) {
|
|
207
|
+
const m = /^(.+)\.(xcodeproj|xcworkspace)$/.exec(e);
|
|
208
|
+
if (m) names.add(m[1]);
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
/* unreadable root: the directory name is still a candidate */
|
|
212
|
+
}
|
|
213
|
+
let best = null;
|
|
214
|
+
for (const c of entries) {
|
|
215
|
+
const dash = c.lastIndexOf("-");
|
|
216
|
+
if (dash <= 0 || !names.has(c.slice(0, dash))) continue;
|
|
217
|
+
const store = join(dd, c, "Index.noindex", "DataStore");
|
|
218
|
+
if (!existsSync(store)) continue;
|
|
219
|
+
const s = statSafe(join(store, "v5", "units")) || statSafe(store);
|
|
220
|
+
const at = s ? s.mtimeMs : 0;
|
|
221
|
+
if (!best || at > best.at) best = { store, at };
|
|
222
|
+
}
|
|
223
|
+
return best ? best.store : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* How many index units there are, and when one was last written.
|
|
228
|
+
*
|
|
229
|
+
* Freshness is taken from the DIRECTORY's mtime, not from a sample of the files
|
|
230
|
+
* in it. A store here holds 34,380 units: stat-ing all of them costs 1.8s, and
|
|
231
|
+
* stat-ing the first 200 of a readdir costs nothing but answers about an
|
|
232
|
+
* arbitrary 200 - directory order is a hash, not a timeline, so the number it
|
|
233
|
+
* produces is the newest of whichever units happened to come first. It was
|
|
234
|
+
* correct on the measured store by luck and nothing made that visible. A unit
|
|
235
|
+
* is written as a new file, which bumps the directory, so one syscall answers
|
|
236
|
+
* exactly: measured at 2ms from the true maximum over all 34,380.
|
|
237
|
+
*/
|
|
238
|
+
function countUnits(dir) {
|
|
239
|
+
let entries;
|
|
240
|
+
try {
|
|
241
|
+
entries = readdirSync(dir);
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
const s = statSafe(dir);
|
|
246
|
+
return { count: entries.length, newest: s ? s.mtimeMs : Date.now() };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export { SKIP_DIRS };
|
|
@@ -3,14 +3,22 @@
|
|
|
3
3
|
* 18 rules consume. Direct port of XCArchiveParser.swift +
|
|
4
4
|
* InfoPlistParser.swift + PrivacyManifestParser.swift + EntitlementParser.swift.
|
|
5
5
|
*
|
|
6
|
-
* Plist parsing
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Plist parsing prefers `plutil -convert json` (system binary on macOS, ships
|
|
7
|
+
* with the OS) - more reliable than any Node `plist` package across the long
|
|
8
|
+
* tail of Apple plist quirks (binary plists, XML-with-DTD, mixed array/dict
|
|
9
|
+
* roots).
|
|
10
|
+
*
|
|
11
|
+
* When plutil is absent it falls back to a small XML reader, and when even that
|
|
12
|
+
* cannot apply - a binary plist off macOS - it records the miss rather than
|
|
13
|
+
* returning an empty object. That distinction is the whole point: every rule
|
|
14
|
+
* reads `infoPlist`, so a silent `{}` makes an unreadable archive audit exactly
|
|
15
|
+
* like a compliant one, and the tool answers PASS because it saw nothing. It
|
|
16
|
+
* shipped that way; a run with no plutil on PATH reported zero violations on a
|
|
17
|
+
* deliberately non-compliant fixture.
|
|
10
18
|
*/
|
|
11
19
|
|
|
12
20
|
import { execSync } from "child_process";
|
|
13
|
-
import { existsSync, readdirSync, statSync } from "fs";
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
14
22
|
import { shq } from "./models.js";
|
|
15
23
|
import { join, basename } from "path";
|
|
16
24
|
|
|
@@ -23,16 +31,159 @@ function run(cmd) {
|
|
|
23
31
|
}
|
|
24
32
|
}
|
|
25
33
|
|
|
26
|
-
/**
|
|
34
|
+
/** True when the `plutil` binary is on PATH. Probed once. */
|
|
35
|
+
let _hasPlutil = null;
|
|
36
|
+
export function hasPlutil() {
|
|
37
|
+
if (_hasPlutil === null) _hasPlutil = run("command -v plutil") !== "";
|
|
38
|
+
return _hasPlutil;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Plists this process could not read, as absolute paths. A rule that finds
|
|
43
|
+
* nothing in an unread plist is not evidence of compliance, and runAudit turns
|
|
44
|
+
* a non-empty list into a finding rather than letting the verdict stand.
|
|
45
|
+
*/
|
|
46
|
+
export const unreadablePlists = new Set();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Empty the record. runAudit calls this before it parses anything, so the set
|
|
50
|
+
* describes the run being reported and not every run this process has done.
|
|
51
|
+
*/
|
|
52
|
+
export function resetUnreadablePlists() {
|
|
53
|
+
unreadablePlists.clear();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Decode the XML entities a plist can carry. */
|
|
57
|
+
function unescapeXml(s) {
|
|
58
|
+
return s
|
|
59
|
+
.replace(/</g, "<")
|
|
60
|
+
.replace(/>/g, ">")
|
|
61
|
+
.replace(/"/g, '"')
|
|
62
|
+
.replace(/'/g, "'")
|
|
63
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCharCode(Number(d)))
|
|
64
|
+
.replace(/&/g, "&");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Minimal XML-plist reader for hosts without plutil.
|
|
69
|
+
*
|
|
70
|
+
* Deliberately small: it handles the element set an Info.plist, an entitlements
|
|
71
|
+
* file and a PrivacyInfo.xcprivacy actually use. It is not a general plist
|
|
72
|
+
* implementation and makes no attempt at binary plists - those return null and
|
|
73
|
+
* are recorded as unreadable, which is the honest answer.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} xml
|
|
76
|
+
* @returns {object|Array|null}
|
|
77
|
+
*/
|
|
78
|
+
export function parseXmlPlist(xml) {
|
|
79
|
+
// <!DOCTYPE ...> and <?xml ...?> carry nothing we need and complicate the scan.
|
|
80
|
+
const body = xml.replace(/<\?xml[^>]*\?>/g, "").replace(/<!DOCTYPE[^>]*>/g, "");
|
|
81
|
+
const tokens = body.match(/<\/?[A-Za-z]+(?:\s[^>]*)?\/?>|[^<]+/g);
|
|
82
|
+
if (!tokens) return null;
|
|
83
|
+
let i = 0;
|
|
84
|
+
|
|
85
|
+
const next = () => tokens[i++];
|
|
86
|
+
const peek = () => tokens[i];
|
|
87
|
+
|
|
88
|
+
function readValue(tag) {
|
|
89
|
+
switch (tag) {
|
|
90
|
+
case "dict": {
|
|
91
|
+
const out = {};
|
|
92
|
+
for (;;) {
|
|
93
|
+
const t = peek();
|
|
94
|
+
if (t === undefined || t === "</dict>") {
|
|
95
|
+
next();
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
if (t === "<key>") {
|
|
99
|
+
next();
|
|
100
|
+
const k = unescapeXml(String(next() ?? "").trim());
|
|
101
|
+
next(); // </key>
|
|
102
|
+
const vt = String(next() ?? "").trim();
|
|
103
|
+
out[k] = readValue(vt.replace(/^<|\/?>$/g, "").split(/\s/)[0]);
|
|
104
|
+
} else {
|
|
105
|
+
next();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
case "array": {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (;;) {
|
|
112
|
+
const t = peek();
|
|
113
|
+
if (t === undefined || t === "</array>") {
|
|
114
|
+
next();
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
const vt = String(next() ?? "").trim();
|
|
118
|
+
if (!vt.startsWith("<")) continue;
|
|
119
|
+
out.push(readValue(vt.replace(/^<|\/?>$/g, "").split(/\s/)[0]));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
case "string":
|
|
123
|
+
case "data":
|
|
124
|
+
case "date": {
|
|
125
|
+
const raw = peek() !== undefined && !String(peek()).startsWith("<") ? String(next()) : "";
|
|
126
|
+
if (String(peek() ?? "").startsWith("</")) next();
|
|
127
|
+
return unescapeXml(raw);
|
|
128
|
+
}
|
|
129
|
+
case "integer":
|
|
130
|
+
case "real": {
|
|
131
|
+
const raw = peek() !== undefined && !String(peek()).startsWith("<") ? String(next()) : "0";
|
|
132
|
+
if (String(peek() ?? "").startsWith("</")) next();
|
|
133
|
+
return Number(raw.trim());
|
|
134
|
+
}
|
|
135
|
+
case "true":
|
|
136
|
+
return true;
|
|
137
|
+
case "false":
|
|
138
|
+
return false;
|
|
139
|
+
default:
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (;;) {
|
|
145
|
+
const t = next();
|
|
146
|
+
if (t === undefined) return null;
|
|
147
|
+
const s = String(t).trim();
|
|
148
|
+
if (!s.startsWith("<plist")) continue;
|
|
149
|
+
for (;;) {
|
|
150
|
+
const u = next();
|
|
151
|
+
if (u === undefined) return null;
|
|
152
|
+
const v = String(u).trim();
|
|
153
|
+
if (!v.startsWith("<")) continue;
|
|
154
|
+
return readValue(v.replace(/^<|\/?>$/g, "").split(/\s/)[0]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Parse a binary or XML plist file into a JS object. */
|
|
27
160
|
export function parsePlist(plistPath) {
|
|
28
161
|
if (!existsSync(plistPath)) return null;
|
|
29
|
-
|
|
30
|
-
|
|
162
|
+
if (hasPlutil()) {
|
|
163
|
+
const json = run(`plutil -convert json -o - ${shq(plistPath)} 2>/dev/null`);
|
|
164
|
+
if (json) {
|
|
165
|
+
try {
|
|
166
|
+
return JSON.parse(json);
|
|
167
|
+
} catch {
|
|
168
|
+
/* fall through to the XML reader */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
let raw;
|
|
31
173
|
try {
|
|
32
|
-
|
|
174
|
+
raw = readFileSync(plistPath, "utf-8");
|
|
33
175
|
} catch {
|
|
176
|
+
unreadablePlists.add(plistPath);
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
// A binary plist starts with the magic "bplist"; there is no reading it here.
|
|
180
|
+
if (raw.startsWith("bplist")) {
|
|
181
|
+
unreadablePlists.add(plistPath);
|
|
34
182
|
return null;
|
|
35
183
|
}
|
|
184
|
+
const parsed = parseXmlPlist(raw);
|
|
185
|
+
if (parsed === null) unreadablePlists.add(plistPath);
|
|
186
|
+
return parsed;
|
|
36
187
|
}
|
|
37
188
|
|
|
38
189
|
/** Recursively measure total size of a directory in bytes. */
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* the rest of the scan.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { parseArchive } from "./context.js";
|
|
24
|
+
import { hasPlutil, parseArchive, resetUnreadablePlists, unreadablePlists } from "./context.js";
|
|
25
25
|
import { compareSeverity } from "./models.js";
|
|
26
26
|
|
|
27
27
|
// ---------- Rule registry --------------------------------------------------
|
|
@@ -93,6 +93,7 @@ function resolveRuleSelection(rules) {
|
|
|
93
93
|
* @returns {Promise<object>} structured audit report
|
|
94
94
|
*/
|
|
95
95
|
export async function runAudit({ archivePath, rules = "all", options = {} } = {}) {
|
|
96
|
+
resetUnreadablePlists();
|
|
96
97
|
const ctx = parseArchive(archivePath);
|
|
97
98
|
|
|
98
99
|
const selectedIDs = new Set(resolveRuleSelection(rules));
|
|
@@ -134,6 +135,25 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
|
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
// A rule that found nothing in a plist nobody could read has not measured
|
|
139
|
+
// anything, and the audit must not let that reach the caller as compliance.
|
|
140
|
+
// Every rule reads infoPlist, so one unread plist can silence all eighteen:
|
|
141
|
+
// the tool answered PASS on a deliberately non-compliant fixture the first
|
|
142
|
+
// time it ran on a host without plutil. Reported as an error so the verdict
|
|
143
|
+
// is not PASS, with the cause named - this is the tool's reach, not the
|
|
144
|
+
// archive's fault.
|
|
145
|
+
const unreadable = [...unreadablePlists].sort();
|
|
146
|
+
if (unreadable.length > 0) {
|
|
147
|
+
violations.push({
|
|
148
|
+
ruleID: "archive-readable",
|
|
149
|
+
severity: "error",
|
|
150
|
+
message: `${unreadable.length} plist(s) could not be read, so the rules below them measured nothing: ${unreadable.join(", ")}`,
|
|
151
|
+
suggestion: hasPlutil()
|
|
152
|
+
? "The files are present but neither plutil nor the XML reader could parse them. Re-export the archive, or open an issue with one of the files attached."
|
|
153
|
+
: "Binary plists need `plutil`, which ships with macOS. Run this audit on macOS, or convert the archive's plists to XML first (`plutil -convert xml1`).",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
137
157
|
// Sort: error → warning → info, then by ruleID alphabetically inside each.
|
|
138
158
|
violations.sort((a, b) => {
|
|
139
159
|
const c = compareSeverity(b.severity, a.severity);
|
|
@@ -156,6 +176,9 @@ export async function runAudit({ archivePath, rules = "all", options = {} } = {}
|
|
|
156
176
|
app: ctx.appName,
|
|
157
177
|
rulesRun: ranIDs,
|
|
158
178
|
rulesSkipped: skippedIDs,
|
|
179
|
+
// Explicit, so a caller can tell "clean" from "could not look".
|
|
180
|
+
measurable: unreadable.length === 0,
|
|
181
|
+
unreadablePlists: unreadable,
|
|
159
182
|
summary,
|
|
160
183
|
verdict,
|
|
161
184
|
violations,
|
package/tools/offload/index.js
CHANGED
|
@@ -58,15 +58,35 @@ function pruneEntries(dir, { keepFiles, keepDays, now, keep, match }) {
|
|
|
58
58
|
}
|
|
59
59
|
})
|
|
60
60
|
.filter(Boolean)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
61
|
+
// Name as the tie-break, because mtime ties are not a corner case. APFS
|
|
62
|
+
// records nanoseconds, so five files written in a loop have five distinct
|
|
63
|
+
// stamps; ext4 on a CI runner records one, so they have the same stamp and
|
|
64
|
+
// "the newest" is whatever order readdir happened to return. Without this
|
|
65
|
+
// the retained set differs per filesystem.
|
|
66
|
+
.sort((a, b) => b.mtime - a.mtime || (a.full < b.full ? 1 : a.full > b.full ? -1 : 0));
|
|
67
|
+
|
|
68
|
+
// keepFiles is the size of the directory after the prune, not the size of
|
|
69
|
+
// the part of it retention got to decide. A promised file is one of the
|
|
70
|
+
// survivors, so it takes one of the slots - reserved up front rather than
|
|
71
|
+
// counted as the loop reaches it, because otherwise the answer depends on
|
|
72
|
+
// where that file happens to sort. It used to be neither: a keepSet entry
|
|
73
|
+
// was skipped without consuming anything, so keepFiles: 1 plus one promised
|
|
74
|
+
// file left TWO files behind. On APFS the promised file is genuinely the
|
|
75
|
+
// newest and sorted first, which hid it; on a filesystem whose mtimes tie it
|
|
76
|
+
// sorted anywhere and the directory kept a stale file forever.
|
|
77
|
+
const reserved = entries.filter((e) => keepSet.has(resolve(e.full))).length;
|
|
78
|
+
const budget = Math.max(0, keepFiles - reserved);
|
|
79
|
+
let kept = 0;
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (keepSet.has(resolve(entry.full))) continue;
|
|
82
|
+
const tooOld = entry.mtime < cutoff;
|
|
83
|
+
const tooMany = kept >= budget;
|
|
84
|
+
if (!tooOld && !tooMany) {
|
|
85
|
+
kept++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
68
88
|
try {
|
|
69
|
-
rmSync(
|
|
89
|
+
rmSync(entry.full, { recursive: true, force: true });
|
|
70
90
|
removed++;
|
|
71
91
|
} catch {
|
|
72
92
|
// A file another process holds open is skipped, not fatal.
|