@eyeauras/insight-cli 0.2.4 → 0.3.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/.cli-package-build.json +1 -1
- package/SKILL.md +4 -1
- package/dist/src/analysis-storage.d.ts +165 -0
- package/dist/src/analysis-storage.js +562 -0
- package/dist/src/cli.js +24 -6
- package/dist/src/contexts.js +8 -8
- package/dist/src/ghidra-processes.js +5 -4
- package/dist/src/initial-model.js +2 -1
- package/dist/src/native-inputs.js +2 -2
- package/dist/src/runtime-manager.js +6 -5
- package/dist/src/runtime-selection.d.ts +2 -2
- package/dist/src/runtime-selection.js +13 -6
- package/dist/src/saved-materials.js +2 -1
- package/dist/src/tool-runtime/installer.js +7 -6
- package/dist/src/tool-runtime/launch-worker.js +3 -1
- package/package.json +2 -2
- package/references/artifacts.md +1 -1
- package/references/managed-runtime.md +1 -1
- package/references/setup.md +4 -0
- package/references/storage.md +99 -0
- package/runtime/toolbox.json +10 -4
- package/skill-bundle/insight-artifacts/SKILL.md +1 -1
- package/skill-bundle/insight-artifacts/references/artifacts.md +1 -1
- package/skill-bundle/insight-artifacts/references/recovery.md +79 -0
- package/skill-bundle/insight-artifacts/references/storage.md +99 -0
- package/skill-bundle/insight-binary/SKILL.md +1 -1
- package/skill-bundle/insight-capture/SKILL.md +1 -1
- package/skill-bundle/insight-cli/SKILL.md +4 -1
- package/skill-bundle/insight-cli/references/recovery.md +79 -0
- package/skill-bundle/insight-cli/references/storage.md +99 -0
- package/skill-bundle/insight-die/SKILL.md +1 -1
- package/skill-bundle/insight-dnspy/SKILL.md +1 -1
- package/skill-bundle/insight-dotnet-dump/SKILL.md +1 -1
- package/skill-bundle/insight-dotnet-stack/SKILL.md +1 -1
- package/skill-bundle/insight-dotnet-trace/SKILL.md +1 -1
- package/skill-bundle/insight-frida/SKILL.md +1 -1
- package/skill-bundle/insight-ghidra/SKILL.md +1 -1
- package/skill-bundle/insight-ghidra/references/artifacts.md +1 -1
- package/skill-bundle/insight-ghidra/references/managed-runtime.md +1 -1
- package/skill-bundle/insight-ghidra/references/setup.md +4 -0
- package/skill-bundle/insight-ghidra/references/storage.md +99 -0
- package/skill-bundle/insight-ilspy/SKILL.md +1 -1
- package/skill-bundle/insight-interop/SKILL.md +1 -1
- package/skill-bundle/insight-lldb/SKILL.md +1 -1
- package/skill-bundle/insight-llvm/SKILL.md +1 -1
- package/skill-bundle/insight-managed-heap/SKILL.md +1 -1
- package/skill-bundle/insight-managed-metadata/SKILL.md +1 -1
- package/skill-bundle/insight-mitmproxy/SKILL.md +1 -1
- package/skill-bundle/insight-overview/SKILL.md +1 -1
- package/skill-bundle/insight-procmon/SKILL.md +1 -1
- package/skill-bundle/insight-re/SKILL.md +1 -1
- package/skill-bundle/insight-re/references/artifacts.md +1 -1
- package/skill-bundle/insight-re/references/storage.md +99 -0
- package/skill-bundle/insight-sevenzip/SKILL.md +1 -1
- package/skill-bundle/insight-strace/SKILL.md +1 -1
- package/skill-bundle/insight-tshark/SKILL.md +1 -1
- package/skill-bundle/insight-windbg/SKILL.md +1 -1
- package/skill-bundle/manifest.json +1 -1
- package/skills/insight-artifacts/SKILL.md +1 -1
- package/skills/insight-artifacts/references/artifacts.md +1 -1
- package/skills/insight-artifacts/references/recovery.md +79 -0
- package/skills/insight-artifacts/references/storage.md +99 -0
- package/skills/insight-binary/SKILL.md +1 -1
- package/skills/insight-capture/SKILL.md +1 -1
- package/skills/insight-die/SKILL.md +1 -1
- package/skills/insight-dnspy/SKILL.md +1 -1
- package/skills/insight-dotnet-dump/SKILL.md +1 -1
- package/skills/insight-dotnet-stack/SKILL.md +1 -1
- package/skills/insight-dotnet-trace/SKILL.md +1 -1
- package/skills/insight-frida/SKILL.md +1 -1
- package/skills/insight-ghidra/SKILL.md +1 -1
- package/skills/insight-ghidra/references/artifacts.md +1 -1
- package/skills/insight-ghidra/references/managed-runtime.md +1 -1
- package/skills/insight-ghidra/references/setup.md +4 -0
- package/skills/insight-ghidra/references/storage.md +99 -0
- package/skills/insight-ilspy/SKILL.md +1 -1
- package/skills/insight-interop/SKILL.md +1 -1
- package/skills/insight-lldb/SKILL.md +1 -1
- package/skills/insight-llvm/SKILL.md +1 -1
- package/skills/insight-managed-heap/SKILL.md +1 -1
- package/skills/insight-managed-metadata/SKILL.md +1 -1
- package/skills/insight-mitmproxy/SKILL.md +1 -1
- package/skills/insight-overview/SKILL.md +1 -1
- package/skills/insight-procmon/SKILL.md +1 -1
- package/skills/insight-re/SKILL.md +1 -1
- package/skills/insight-re/references/artifacts.md +1 -1
- package/skills/insight-re/references/storage.md +99 -0
- package/skills/insight-sevenzip/SKILL.md +1 -1
- package/skills/insight-strace/SKILL.md +1 -1
- package/skills/insight-tshark/SKILL.md +1 -1
- package/skills/insight-windbg/SKILL.md +1 -1
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import { CliError, Permission, ProfileStore, privateDirectory, command } from '@eyeauras/cli-factory';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { access, lstat, mkdir, open, readdir, readlink, realpath, rename, rm, statfs, symlink, unlink } from 'node:fs/promises';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { atomicWrite, readJson, toolProfileBusy } from './tool-runtime/store.js';
|
|
7
|
+
import { listProcesses } from './tool-runtime/supervisor.js';
|
|
8
|
+
import { runtimeProcesses } from './runtime-manager.js';
|
|
9
|
+
import { digest } from './model.js';
|
|
10
|
+
// Move the complete runtime tree too: installation stages must share a filesystem with
|
|
11
|
+
// installations, and both native checks and process runs can contain large generated DBs.
|
|
12
|
+
// Stable aliases preserve references in immutable native journals, selections and results.
|
|
13
|
+
export const analysisTrees = ['workspace', 'materials', 'temp', 'runtime'];
|
|
14
|
+
const terminal = (state) => ['committed', 'rolled_back', 'cleaned'].includes(state);
|
|
15
|
+
const inside = (parent, child) => { const r = relative(parent, child); return r === '' || !isAbsolute(r) && r !== '..' && !r.startsWith('..' + sep); };
|
|
16
|
+
function fail(code, message) { throw new CliError(message, { code: 'storage.' + code }); }
|
|
17
|
+
const missing = (e) => e.code === 'ENOENT';
|
|
18
|
+
async function present(path) { try {
|
|
19
|
+
return await lstat(path);
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
if (missing(e))
|
|
23
|
+
return;
|
|
24
|
+
throw e;
|
|
25
|
+
} }
|
|
26
|
+
async function sync(path, directory = false) {
|
|
27
|
+
if (directory && process.platform === 'win32')
|
|
28
|
+
return;
|
|
29
|
+
// Windows FlushFileBuffers requires write access even when no bytes are changed.
|
|
30
|
+
const f = await open(path, process.platform === 'win32' && !directory ? 'r+' : 'r');
|
|
31
|
+
try {
|
|
32
|
+
await f.sync();
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await f.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function analysisDirectory(c) {
|
|
39
|
+
const value = c.profile.values.analysisDirectory;
|
|
40
|
+
if (value === undefined || value === null)
|
|
41
|
+
return c.appArguments.AppDataDirectory;
|
|
42
|
+
if (typeof value !== 'string' || !isAbsolute(value) || value !== resolve(value))
|
|
43
|
+
fail('configuration', 'Invalid analysis directory in the Factory profile.');
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
function stamp(st) { return [st.dev, st.ino, st.mode, st.size, st.mtimeNs, st.ctimeNs].join(':'); }
|
|
47
|
+
async function hash(path, signal) {
|
|
48
|
+
const f = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
49
|
+
try {
|
|
50
|
+
const st = await f.stat({ bigint: true });
|
|
51
|
+
if (!st.isFile())
|
|
52
|
+
fail('changed', 'Analysis file is no longer regular.');
|
|
53
|
+
const h = createHash('sha256');
|
|
54
|
+
for await (const chunk of f.createReadStream({ autoClose: false })) {
|
|
55
|
+
signal?.throwIfAborted();
|
|
56
|
+
h.update(chunk);
|
|
57
|
+
}
|
|
58
|
+
if (stamp(st) !== stamp(await f.stat({ bigint: true })))
|
|
59
|
+
fail('changed', 'Analysis file changed while checking it.');
|
|
60
|
+
return h.digest('hex');
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await f.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function inventory(root, trees, hashes, signal, validateLinks = true) {
|
|
67
|
+
const entries = [];
|
|
68
|
+
async function visit(path, depth) {
|
|
69
|
+
signal?.throwIfAborted();
|
|
70
|
+
if (depth > 128 || entries.length >= 1_000_000)
|
|
71
|
+
fail('limit', 'Analysis inventory exceeds one million entries or 128 directory levels.');
|
|
72
|
+
const at = join(root, path), st = await lstat(at, { bigint: true });
|
|
73
|
+
if (st.size > BigInt(Number.MAX_SAFE_INTEGER))
|
|
74
|
+
fail('limit', 'Analysis file exceeds the supported size counter.');
|
|
75
|
+
const base = { path, size: st.isFile() ? Number(st.size) : 0, mode: Number(st.mode) & 0o777, stamp: stamp(st) };
|
|
76
|
+
if (st.isDirectory()) {
|
|
77
|
+
entries.push({ ...base, kind: 'directory' });
|
|
78
|
+
for (const name of (await readdir(at)).sort())
|
|
79
|
+
await visit(path + '/' + name, depth + 1);
|
|
80
|
+
if (stamp(st) !== stamp(await lstat(at, { bigint: true })))
|
|
81
|
+
fail('changed', 'Analysis directory changed during inventory.');
|
|
82
|
+
}
|
|
83
|
+
else if (st.isFile())
|
|
84
|
+
entries.push({ ...base, kind: 'file', ...(hashes ? { sha256: await hash(at, signal) } : {}) });
|
|
85
|
+
else if (st.isSymbolicLink()) {
|
|
86
|
+
if (validateLinks) {
|
|
87
|
+
const target = await realpath(at);
|
|
88
|
+
if (!analysisTrees.some(tree => inside(join(root, tree), target)))
|
|
89
|
+
fail('link', 'An analysis link escapes the migrated trees; retain and inspect it before migration.');
|
|
90
|
+
}
|
|
91
|
+
entries.push({ ...base, kind: 'link', link: await readlink(at) });
|
|
92
|
+
}
|
|
93
|
+
else
|
|
94
|
+
fail('type', 'Analysis storage contains a special file; no migration was started.');
|
|
95
|
+
}
|
|
96
|
+
for (const tree of trees)
|
|
97
|
+
if (await present(join(root, tree)))
|
|
98
|
+
await visit(tree, 0);
|
|
99
|
+
return entries;
|
|
100
|
+
}
|
|
101
|
+
function content(entries) { return entries.map(({ path, kind, size, sha256, link }) => ({ path, kind, size, ...(sha256 ? { sha256 } : {}), ...(link ? { link } : {}) })); }
|
|
102
|
+
async function canonicalFuture(path) {
|
|
103
|
+
const st = await present(path);
|
|
104
|
+
if (st) {
|
|
105
|
+
if (st.isSymbolicLink() || !st.isDirectory())
|
|
106
|
+
fail('path', 'Choose an ordinary directory, without a symlink at its root.');
|
|
107
|
+
return realpath(path);
|
|
108
|
+
}
|
|
109
|
+
const parent = dirname(path);
|
|
110
|
+
if (parent === path)
|
|
111
|
+
fail('path', 'No existing parent for the destination.');
|
|
112
|
+
return join(await canonicalFuture(parent), basename(path));
|
|
113
|
+
}
|
|
114
|
+
async function existingParent(path) { return await present(path) ? path : existingParent(dirname(path)); }
|
|
115
|
+
async function directoryIdentity(path) { const st = await lstat(path, { bigint: true }); if (!st.isDirectory() || await realpath(path) !== path)
|
|
116
|
+
fail('volume', 'Migration volume or directory was replaced.'); return [st.dev, st.ino].join(':'); }
|
|
117
|
+
/** Factory remains the only owner of profiles, settings, keyring and IPC admission. */
|
|
118
|
+
export class AnalysisStorage {
|
|
119
|
+
storeOverride;
|
|
120
|
+
release;
|
|
121
|
+
checkpoint;
|
|
122
|
+
active = new Map();
|
|
123
|
+
changing = new Set();
|
|
124
|
+
constructor(storeOverride, release, checkpoint) {
|
|
125
|
+
this.storeOverride = storeOverride;
|
|
126
|
+
this.release = release;
|
|
127
|
+
this.checkpoint = checkpoint;
|
|
128
|
+
}
|
|
129
|
+
control(c) { return join(c.appArguments.AppDataDirectory, 'storage'); }
|
|
130
|
+
store(c) { return this.storeOverride ?? new ProfileStore({ applicationId: c.appArguments.AppName, appArguments: c.appArguments }); }
|
|
131
|
+
async binding(c) { await mkdir(c.appArguments.AppDataDirectory, { recursive: true, mode: 0o700 }); return digest(await realpath(c.appArguments.AppDataDirectory)); }
|
|
132
|
+
async journal(c, loadEntries = false) {
|
|
133
|
+
const record = await readJson(join(this.control(c), 'migration.json'));
|
|
134
|
+
if (!record)
|
|
135
|
+
return;
|
|
136
|
+
if (!/^[-a-f0-9]{36}$/.test(record.id))
|
|
137
|
+
fail('journal', 'Invalid migration identity.');
|
|
138
|
+
const j = { ...record, entries: loadEntries ? (await readJson(join(this.control(c), 'manifests', record.id + '.json'))) ?? [] : [] };
|
|
139
|
+
if (j.schema !== 1 || j.profile !== c.profile.name || j.binding !== await this.binding(c) || !/^[-a-f0-9]{36}$/.test(j.id) ||
|
|
140
|
+
!isAbsolute(j.source) || !isAbsolute(j.destination) || inside(j.source, j.destination) || inside(j.destination, j.source) ||
|
|
141
|
+
!isAbsolute(j.destinationAnchor) || !inside(j.destinationAnchor, j.destination) || typeof j.sourceIdentity !== 'string' || typeof j.destinationIdentity !== 'string' ||
|
|
142
|
+
!['copying', 'verified', 'activating', 'committed', 'rolling_back', 'rolled_back', 'cleaning', 'cleaned'].includes(j.state) ||
|
|
143
|
+
!Array.isArray(j.trees) || j.trees.some(t => !analysisTrees.includes(t)) || !Array.isArray(j.entries) ||
|
|
144
|
+
j.entries.some(e => !j.trees.some(t => e.path === t || e.path.startsWith(t + '/')) || e.path.split('/').some(p => p === '..' || p === '.' || p === '')) ||
|
|
145
|
+
!/^[a-f0-9]{64}$/.test(j.manifest) || loadEntries && j.manifest !== digest(content(j.entries)))
|
|
146
|
+
fail('journal', 'Migration journal failed its profile/path/integrity binding; preserve it.');
|
|
147
|
+
return j;
|
|
148
|
+
}
|
|
149
|
+
async save(c, j) {
|
|
150
|
+
const manifest = join(this.control(c), 'manifests', j.id + '.json');
|
|
151
|
+
if (!await present(manifest))
|
|
152
|
+
await atomicWrite(manifest, j.entries);
|
|
153
|
+
const { entries, ...record } = j;
|
|
154
|
+
await atomicWrite(join(this.control(c), 'migration.json'), record);
|
|
155
|
+
}
|
|
156
|
+
async validate(c) {
|
|
157
|
+
const root = analysisDirectory(c), j = await this.journal(c);
|
|
158
|
+
if (j && !terminal(j.state))
|
|
159
|
+
fail('pending', 'Migration is incomplete. Use storage show, resume or rollback before research.');
|
|
160
|
+
if (j && ['committed', 'cleaned'].includes(j.state) && root !== j.destination)
|
|
161
|
+
fail('configuration', 'Factory profile and committed migration differ; preserve both and inspect storage show.');
|
|
162
|
+
if (root === c.appArguments.AppDataDirectory)
|
|
163
|
+
return;
|
|
164
|
+
const marker = await readJson(join(root, '.insight-analysis.json'));
|
|
165
|
+
if (!marker || marker.schema !== 1 || marker.binding !== await this.binding(c) || await realpath(root) !== root)
|
|
166
|
+
fail('unavailable', 'Configured analysis storage is missing or changed; mount its volume. No fallback was created.');
|
|
167
|
+
if (j && ['committed', 'cleaned'].includes(j.state) && j.targetIdentity && await directoryIdentity(root) !== j.targetIdentity)
|
|
168
|
+
fail('binding', 'Committed analysis directory was replaced.');
|
|
169
|
+
for (const tree of analysisTrees) {
|
|
170
|
+
if (await realpath(join(c.appArguments.AppDataDirectory, tree)) !== join(root, tree))
|
|
171
|
+
fail('binding', 'An analysis compatibility path was replaced; inspect storage show.');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
wrap(entry) {
|
|
175
|
+
const run = entry.run;
|
|
176
|
+
return { ...entry, ...(entry.children ? { children: entry.children.map(e => this.wrap(e)) } : {}), ...(run ? { run: async (input, c) => {
|
|
177
|
+
const key = c.appArguments.AppDataDirectory;
|
|
178
|
+
if (this.changing.has(key))
|
|
179
|
+
fail('busy', 'Storage migration is in progress.');
|
|
180
|
+
this.active.set(key, (this.active.get(key) ?? 0) + 1);
|
|
181
|
+
try {
|
|
182
|
+
await this.validate(c);
|
|
183
|
+
return await run(input, c);
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
const n = this.active.get(key) - 1;
|
|
187
|
+
if (n)
|
|
188
|
+
this.active.set(key, n);
|
|
189
|
+
else
|
|
190
|
+
this.active.delete(key);
|
|
191
|
+
}
|
|
192
|
+
} } : {}) };
|
|
193
|
+
}
|
|
194
|
+
busy(directory) { return this.changing.has(directory); }
|
|
195
|
+
async exclusive(c, action) {
|
|
196
|
+
const key = c.appArguments.AppDataDirectory;
|
|
197
|
+
if (this.changing.has(key) || this.active.get(key) || toolProfileBusy(key))
|
|
198
|
+
fail('busy', 'Finish active commands before storage migration.');
|
|
199
|
+
this.changing.add(key);
|
|
200
|
+
try {
|
|
201
|
+
const processes = (await listProcesses(key)).filter(p => !['exited', 'failed'].includes(p.state));
|
|
202
|
+
const native = await runtimeProcesses(key);
|
|
203
|
+
if (processes.length || native.length)
|
|
204
|
+
throw new CliError('Close the profile contexts and stop its owned server/processes before migration. Unverified lifetimes also block copying.', { code: 'storage.busy', result: { processes, native }, next: [['tools', 'processes']] });
|
|
205
|
+
await this.release(c);
|
|
206
|
+
return await action();
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
this.changing.delete(key);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
async preflight(c, directory) {
|
|
213
|
+
if (typeof directory !== 'string' || !isAbsolute(directory) || /[\0\r\n]/.test(directory))
|
|
214
|
+
fail('path', 'Supply an absolute destination base directory.');
|
|
215
|
+
const source = await canonicalFuture(analysisDirectory(c)), destination = await canonicalFuture(join(resolve(directory), c.appArguments.AppName, c.profile.name));
|
|
216
|
+
const app = await canonicalFuture(c.appArguments.RoamingAppDataDirectory);
|
|
217
|
+
if (inside(source, destination) || inside(destination, source) || inside(app, destination) || inside(destination, app))
|
|
218
|
+
fail('path', 'Source, destination and profile configuration roots must be separate, non-nested directories.');
|
|
219
|
+
const current = await this.journal(c);
|
|
220
|
+
if (current && !['cleaned', 'rolled_back'].includes(current.state))
|
|
221
|
+
fail('pending', 'Finish or roll back the current migration; clean its retained source before starting another.');
|
|
222
|
+
const st = await present(destination);
|
|
223
|
+
if (st && (await readdir(destination)).length)
|
|
224
|
+
fail('destination', 'Destination profile directory must be empty; existing data is never adopted or overwritten.');
|
|
225
|
+
const parent = await existingParent(destination);
|
|
226
|
+
await access(parent, constants.R_OK | constants.W_OK | constants.X_OK);
|
|
227
|
+
if (await present(source))
|
|
228
|
+
await access(source, constants.R_OK | constants.W_OK | constants.X_OK);
|
|
229
|
+
const entries = await inventory(source, analysisTrees, false, c.signal), bytes = entries.reduce((n, e) => n + e.size, 0);
|
|
230
|
+
if (!Number.isSafeInteger(bytes))
|
|
231
|
+
fail('limit', 'Analysis inventory exceeds the supported size counter.');
|
|
232
|
+
const fs = await statfs(parent, { bigint: true }), available = fs.bavail * fs.bsize, reserve = BigInt(entries.length) * 8192n + 16n * 1024n * 1024n;
|
|
233
|
+
if (available < BigInt(bytes) + reserve)
|
|
234
|
+
fail('capacity', 'Destination has insufficient free space for data and migration overhead.');
|
|
235
|
+
return { source, destination, destinationAnchor: parent, destinationIdentity: await directoryIdentity(parent), bytes, entries: entries.length, availableBytes: available.toString(), requiredBytes: (BigInt(bytes) + reserve).toString(), trees: analysisTrees, sourceRetained: true, compatibilityPathsRetained: true };
|
|
236
|
+
}
|
|
237
|
+
async show(c) {
|
|
238
|
+
const j = await this.journal(c), root = analysisDirectory(c);
|
|
239
|
+
let usage, availability = 'available';
|
|
240
|
+
try {
|
|
241
|
+
await this.validate(c);
|
|
242
|
+
const entries = await inventory(root, analysisTrees, false, c.signal);
|
|
243
|
+
usage = { files: entries.filter(e => e.kind === 'file').length, bytes: entries.reduce((n, e) => n + e.size, 0) };
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
availability = error instanceof CliError ? error.code : (error.code ?? 'unavailable');
|
|
247
|
+
}
|
|
248
|
+
return { profile: c.profile.name, directory: root, configuration: 'Factory profile analysisDirectory', defaultDirectory: c.appArguments.AppDataDirectory, availability, usage,
|
|
249
|
+
trees: analysisTrees, appData: c.appArguments.AppDataDirectory, included: ['snapshots, immutable inputs, native projects and local server repositories', 'tool execution/verification working data', 'runtime installations, acquisition caches, selections and process receipts'], excluded: ['profiles, permissions, IPC, logs and OS keyring secrets'],
|
|
250
|
+
migration: j ? { id: j.id, state: j.state, source: j.source, destination: j.destination, manifest: j.manifest, bytes: j.bytes, files: j.files, retainedSource: join(j.source, '.storage-retained', j.id), retainedSourceBytes: j.state === 'committed' ? j.bytes : 0, progress: await readJson(join(this.control(c), 'progress.json')) } : null };
|
|
251
|
+
}
|
|
252
|
+
async migrate(c, directory, dryRun = false, emptyOnly = false) {
|
|
253
|
+
return this.exclusive(c, async () => {
|
|
254
|
+
await this.validate(c);
|
|
255
|
+
const plan = await this.preflight(c, directory);
|
|
256
|
+
if (dryRun)
|
|
257
|
+
return { state: 'preflight', ...plan };
|
|
258
|
+
if (emptyOnly && plan.entries)
|
|
259
|
+
fail('data', 'This profile already has analysis data. Use storage migrate --directory to preserve it.');
|
|
260
|
+
const entries = await inventory(plan.source, analysisTrees, true, c.signal), id = randomUUID();
|
|
261
|
+
const binding = await this.binding(c);
|
|
262
|
+
const j = { schema: 1, id, profile: c.profile.name, binding, source: plan.source, destination: plan.destination,
|
|
263
|
+
sourceIdentity: await directoryIdentity(plan.source), destinationAnchor: plan.destinationAnchor, destinationIdentity: plan.destinationIdentity,
|
|
264
|
+
previous: typeof c.profile.values.analysisDirectory === 'string' ? c.profile.values.analysisDirectory : null, state: 'copying', entries, trees: [...analysisTrees], manifest: digest(content(entries)), createdAt: new Date().toISOString(), bytes: entries.reduce((n, e) => n + e.size, 0), files: entries.filter(e => e.kind === 'file').length };
|
|
265
|
+
// Reserve durably before any destination or source changes. All ordinary commands now freeze.
|
|
266
|
+
await this.save(c, j);
|
|
267
|
+
await this.checkpoint?.('reserved');
|
|
268
|
+
return this.continue(c, j);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
async checkCopy(c, j) {
|
|
272
|
+
if (await canonicalFuture(j.destination) !== j.destination || j.targetIdentity && await directoryIdentity(j.destination) !== j.targetIdentity)
|
|
273
|
+
fail('binding', 'Destination directory was replaced.');
|
|
274
|
+
const actual = await inventory(j.destination, j.trees, true, c.signal, false);
|
|
275
|
+
// Empty managed roots also exist to receive future writes; normalize them out of the manifest.
|
|
276
|
+
const expected = content(j.entries), actualContent = content(actual).filter(e => !j.trees.includes(e.path) || expected.some(x => x.path === e.path));
|
|
277
|
+
if (digest(actualContent) !== j.manifest)
|
|
278
|
+
fail('verification', 'Copied analysis bytes or directory membership differ; source remains retained.');
|
|
279
|
+
}
|
|
280
|
+
async continue(c, j) {
|
|
281
|
+
if (await directoryIdentity(j.source) !== j.sourceIdentity || await directoryIdentity(j.destinationAnchor) !== j.destinationIdentity)
|
|
282
|
+
fail('volume', 'A migration volume changed or disappeared. Mount the original volume before resuming; no fallback was created.');
|
|
283
|
+
if (await canonicalFuture(j.destination) !== j.destination || j.targetIdentity && await directoryIdentity(j.destination) !== j.targetIdentity)
|
|
284
|
+
fail('binding', 'Destination directory was replaced; no migration write was made.');
|
|
285
|
+
if (j.state === 'rolling_back')
|
|
286
|
+
return this.restore(c, j);
|
|
287
|
+
if (j.state === 'cleaning')
|
|
288
|
+
return this.finishCleanup(c, j);
|
|
289
|
+
if (terminal(j.state))
|
|
290
|
+
return this.show(c);
|
|
291
|
+
if (j.state === 'copying') {
|
|
292
|
+
await privateDirectory(j.destination);
|
|
293
|
+
if (!j.targetIdentity) {
|
|
294
|
+
if ((await readdir(j.destination)).length)
|
|
295
|
+
fail('destination', 'Unreserved destination contents appeared before copying.');
|
|
296
|
+
j.targetIdentity = await directoryIdentity(j.destination);
|
|
297
|
+
await this.save(c, j);
|
|
298
|
+
}
|
|
299
|
+
let remaining = 0n;
|
|
300
|
+
for (const e of j.entries)
|
|
301
|
+
if (e.kind === 'file' && !await present(join(j.destination, e.path))) {
|
|
302
|
+
if (await canonicalFuture(dirname(join(j.destination, e.path))) !== dirname(join(j.destination, e.path)))
|
|
303
|
+
fail('binding', 'A destination parent was replaced.');
|
|
304
|
+
// Reclaim only this transaction's unacknowledged file before checking capacity.
|
|
305
|
+
await unlink(join(j.destination, e.path) + '.insight-copy-' + j.id).catch(e => { if (!missing(e))
|
|
306
|
+
throw e; });
|
|
307
|
+
remaining += BigInt(e.size);
|
|
308
|
+
}
|
|
309
|
+
const fs = await statfs(j.destinationAnchor, { bigint: true });
|
|
310
|
+
if (fs.bavail * fs.bsize < remaining + BigInt(j.entries.length) * 8192n + 16n * 1024n * 1024n)
|
|
311
|
+
fail('capacity', 'Destination no longer has sufficient free space; source and journal remain retained.');
|
|
312
|
+
const marker = join(j.destination, '.insight-analysis.json'), old = await readJson(marker);
|
|
313
|
+
if (old && (old.id !== j.id || old.binding !== j.binding))
|
|
314
|
+
fail('destination', 'Destination is claimed by another migration.');
|
|
315
|
+
await atomicWrite(marker, { schema: 1, id: j.id, binding: j.binding, profile: j.profile });
|
|
316
|
+
for (const tree of j.trees) {
|
|
317
|
+
const path = join(j.destination, tree);
|
|
318
|
+
if (await canonicalFuture(path) !== path)
|
|
319
|
+
fail('binding', 'A destination tree was replaced.');
|
|
320
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
321
|
+
}
|
|
322
|
+
let completed = 0;
|
|
323
|
+
for (const entry of j.entries) {
|
|
324
|
+
c.signal.throwIfAborted();
|
|
325
|
+
const at = join(j.destination, entry.path), source = join(j.source, entry.path);
|
|
326
|
+
if (await canonicalFuture(entry.kind === 'directory' ? at : dirname(at)) !== (entry.kind === 'directory' ? at : dirname(at)))
|
|
327
|
+
fail('binding', 'A destination directory was replaced.');
|
|
328
|
+
if (entry.kind === 'directory')
|
|
329
|
+
await mkdir(at, { recursive: true, mode: 0o700 });
|
|
330
|
+
else if (entry.kind === 'link') {
|
|
331
|
+
const st = await present(at);
|
|
332
|
+
if (st) {
|
|
333
|
+
if (!st.isSymbolicLink() || await readlink(at) !== entry.link)
|
|
334
|
+
fail('destination', 'A destination link changed.');
|
|
335
|
+
}
|
|
336
|
+
else
|
|
337
|
+
await symlink(entry.link, at);
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
const st = await present(at);
|
|
341
|
+
if (st) {
|
|
342
|
+
if (!st.isFile() || st.size !== entry.size || await hash(at, c.signal) !== entry.sha256)
|
|
343
|
+
fail('verification', 'A completed destination file changed; preserve and inspect it.');
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
const partial = at + '.insight-copy-' + j.id;
|
|
347
|
+
await rm(partial, { force: true });
|
|
348
|
+
const input = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK), output = await open(partial, 'wx', 0o600);
|
|
349
|
+
try {
|
|
350
|
+
if (stamp(await input.stat({ bigint: true })) !== entry.stamp)
|
|
351
|
+
fail('changed', 'Source changed since migration reservation. Roll back before retrying.');
|
|
352
|
+
const h = createHash('sha256');
|
|
353
|
+
let size = 0;
|
|
354
|
+
for await (const chunk of input.createReadStream({ autoClose: false })) {
|
|
355
|
+
c.signal.throwIfAborted();
|
|
356
|
+
size += chunk.length;
|
|
357
|
+
h.update(chunk);
|
|
358
|
+
await output.writeFile(chunk);
|
|
359
|
+
}
|
|
360
|
+
if (size !== entry.size || h.digest('hex') !== entry.sha256 || stamp(await input.stat({ bigint: true })) !== entry.stamp)
|
|
361
|
+
fail('changed', 'Source changed during copy.');
|
|
362
|
+
await output.chmod((entry.mode & 0o700) | 0o400);
|
|
363
|
+
await output.sync();
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
await input.close();
|
|
367
|
+
await output.close();
|
|
368
|
+
}
|
|
369
|
+
await rename(partial, at);
|
|
370
|
+
await sync(dirname(at), true);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
await this.checkpoint?.('copied');
|
|
374
|
+
if (++completed % 32 === 0)
|
|
375
|
+
await atomicWrite(join(this.control(c), 'progress.json'), { completed, total: j.entries.length, phase: 'copying' });
|
|
376
|
+
}
|
|
377
|
+
const source = await inventory(j.source, j.trees, true, c.signal);
|
|
378
|
+
if (digest(source) !== digest(j.entries))
|
|
379
|
+
fail('changed', 'Source changed while copying; switching was refused.');
|
|
380
|
+
await this.checkCopy(c, j);
|
|
381
|
+
for (const e of [...j.entries].reverse())
|
|
382
|
+
if (e.kind === 'directory')
|
|
383
|
+
await sync(join(j.destination, e.path), true);
|
|
384
|
+
await sync(j.destination, true);
|
|
385
|
+
j.state = 'verified';
|
|
386
|
+
await this.save(c, j);
|
|
387
|
+
}
|
|
388
|
+
if (j.state === 'verified') {
|
|
389
|
+
const source = await inventory(j.source, j.trees, true, c.signal);
|
|
390
|
+
if (digest(source) !== digest(j.entries))
|
|
391
|
+
fail('changed', 'Source changed before activation.');
|
|
392
|
+
await this.checkCopy(c, j);
|
|
393
|
+
j.state = 'activating';
|
|
394
|
+
await this.save(c, j);
|
|
395
|
+
}
|
|
396
|
+
if (j.state === 'activating') {
|
|
397
|
+
await this.checkCopy(c, j);
|
|
398
|
+
const backupRoot = join(j.source, '.storage-retained', j.id), backupParent = dirname(backupRoot);
|
|
399
|
+
if (!j.backupIdentity) {
|
|
400
|
+
if (await canonicalFuture(backupRoot) !== backupRoot)
|
|
401
|
+
fail('binding', 'Retained source ancestry was replaced.');
|
|
402
|
+
await mkdir(backupRoot, { recursive: true, mode: 0o700 });
|
|
403
|
+
if ((await readdir(backupRoot)).length)
|
|
404
|
+
fail('binding', 'Unreserved retained source contents appeared.');
|
|
405
|
+
j.backupIdentity = await directoryIdentity(backupRoot);
|
|
406
|
+
j.backupParentIdentity = await directoryIdentity(backupParent);
|
|
407
|
+
await this.save(c, j);
|
|
408
|
+
}
|
|
409
|
+
await this.checkBackup(j);
|
|
410
|
+
// After this durable intent, cancellation leaves a recoverable alias transaction.
|
|
411
|
+
for (const tree of j.trees) {
|
|
412
|
+
const source = join(j.source, tree), backup = join(j.source, '.storage-retained', j.id, tree), target = join(j.destination, tree);
|
|
413
|
+
const st = await present(source);
|
|
414
|
+
if (st?.isSymbolicLink()) {
|
|
415
|
+
if (await realpath(source) !== target)
|
|
416
|
+
fail('binding', 'An activating source alias changed.');
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (st) {
|
|
420
|
+
await mkdir(dirname(backup), { recursive: true, mode: 0o700 });
|
|
421
|
+
if (await present(backup))
|
|
422
|
+
fail('binding', 'Retained source already exists beside an ordinary source.');
|
|
423
|
+
await rename(source, backup);
|
|
424
|
+
await sync(dirname(source), true);
|
|
425
|
+
await sync(dirname(backup), true);
|
|
426
|
+
await this.checkpoint?.('source-retained');
|
|
427
|
+
}
|
|
428
|
+
await mkdir(dirname(source), { recursive: true, mode: 0o700 });
|
|
429
|
+
await symlink(target, source, process.platform === 'win32' ? 'junction' : 'dir');
|
|
430
|
+
await sync(dirname(source), true);
|
|
431
|
+
}
|
|
432
|
+
await this.checkCopy(c, j);
|
|
433
|
+
await this.checkpoint?.('aliases-installed');
|
|
434
|
+
await this.store(c).set(c.profile.name, { analysisDirectory: j.destination });
|
|
435
|
+
await sync(join(c.appArguments.RoamingAppDataDirectory, 'profiles.json'));
|
|
436
|
+
await sync(c.appArguments.RoamingAppDataDirectory, true);
|
|
437
|
+
await this.checkpoint?.('profile-switched');
|
|
438
|
+
j.state = 'committed';
|
|
439
|
+
await this.save(c, j);
|
|
440
|
+
await atomicWrite(join(this.control(c), 'progress.json'), { completed: j.entries.length, total: j.entries.length, phase: 'committed' });
|
|
441
|
+
}
|
|
442
|
+
return { state: j.state, id: j.id, directory: j.destination, manifest: j.manifest, sourceRetained: true, rollback: 'storage rollback', cleanup: 'storage cleanup' };
|
|
443
|
+
}
|
|
444
|
+
async resume(c) { return this.exclusive(c, async () => { const j = await this.journal(c, true); if (!j)
|
|
445
|
+
fail('missing', 'No migration journal exists.'); return this.continue(c, j); }); }
|
|
446
|
+
async rollback(c) {
|
|
447
|
+
return this.exclusive(c, async () => {
|
|
448
|
+
const j = await this.journal(c, true);
|
|
449
|
+
if (!j)
|
|
450
|
+
fail('missing', 'No migration journal exists.');
|
|
451
|
+
if (j.state === 'cleaned' || j.state === 'cleaning')
|
|
452
|
+
fail('retention', 'Retained source cleanup has begun. Resume cleanup; rollback can no longer restore a complete source.');
|
|
453
|
+
if (j.state === 'rolled_back')
|
|
454
|
+
return { state: j.state, id: j.id };
|
|
455
|
+
if (j.state === 'committed' || j.state === 'activating')
|
|
456
|
+
await this.checkCopy(c, j);
|
|
457
|
+
if (j.state === 'copying' || j.state === 'verified')
|
|
458
|
+
j.rollbackUnactivated = true;
|
|
459
|
+
else
|
|
460
|
+
await this.checkRetained(c, j);
|
|
461
|
+
j.state = 'rolling_back';
|
|
462
|
+
await this.save(c, j);
|
|
463
|
+
return this.restore(c, j);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
async restore(c, j) {
|
|
467
|
+
if (j.rollbackUnactivated) {
|
|
468
|
+
if (await directoryIdentity(j.source) !== j.sourceIdentity)
|
|
469
|
+
fail('volume', 'Original source volume changed; rollback refused.');
|
|
470
|
+
}
|
|
471
|
+
else
|
|
472
|
+
await this.checkRetained(c, j);
|
|
473
|
+
for (const tree of j.rollbackUnactivated ? [] : j.trees) {
|
|
474
|
+
const source = join(j.source, tree), backup = join(j.source, '.storage-retained', j.id, tree), st = await present(source);
|
|
475
|
+
if (st?.isSymbolicLink()) {
|
|
476
|
+
if (await realpath(source) !== join(j.destination, tree))
|
|
477
|
+
fail('binding', 'Rollback source alias changed.');
|
|
478
|
+
await unlink(source);
|
|
479
|
+
await sync(dirname(source), true);
|
|
480
|
+
}
|
|
481
|
+
if (await present(backup)) {
|
|
482
|
+
if (await present(source))
|
|
483
|
+
fail('binding', 'Rollback destination contains unexpected data.');
|
|
484
|
+
await rename(backup, source);
|
|
485
|
+
await sync(dirname(source), true);
|
|
486
|
+
await sync(dirname(backup), true);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
await this.store(c).set(c.profile.name, { analysisDirectory: j.previous });
|
|
490
|
+
await sync(join(c.appArguments.RoamingAppDataDirectory, 'profiles.json'));
|
|
491
|
+
await sync(c.appArguments.RoamingAppDataDirectory, true);
|
|
492
|
+
j.state = 'rolled_back';
|
|
493
|
+
await this.save(c, j);
|
|
494
|
+
return { state: j.state, id: j.id, directory: j.source, destinationRetained: j.destination };
|
|
495
|
+
}
|
|
496
|
+
async cleanup(c) {
|
|
497
|
+
return this.exclusive(c, async () => {
|
|
498
|
+
const j = await this.journal(c, true);
|
|
499
|
+
if (!j || !['committed', 'cleaned'].includes(j.state))
|
|
500
|
+
fail('retention', 'Cleanup requires a committed migration.');
|
|
501
|
+
if (j.state === 'cleaned')
|
|
502
|
+
return { state: j.state, id: j.id };
|
|
503
|
+
await this.validate(c);
|
|
504
|
+
const root = join(j.source, '.storage-retained', j.id), retained = await inventory(root, j.trees, true, c.signal, false);
|
|
505
|
+
if (digest(content(retained)) !== j.manifest)
|
|
506
|
+
fail('changed', 'Retained source changed; cleanup refused. Preserve it for inspection.');
|
|
507
|
+
j.state = 'cleaning';
|
|
508
|
+
await this.save(c, j);
|
|
509
|
+
await this.checkpoint?.('cleanup-reserved');
|
|
510
|
+
return this.finishCleanup(c, j);
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
async finishCleanup(c, j) {
|
|
514
|
+
await this.checkBackup(j, true);
|
|
515
|
+
const root = join(j.source, '.storage-retained', j.id);
|
|
516
|
+
await rm(root, { recursive: true, force: true });
|
|
517
|
+
if (await present(dirname(root)))
|
|
518
|
+
await sync(dirname(root), true);
|
|
519
|
+
j.state = 'cleaned';
|
|
520
|
+
await this.save(c, j);
|
|
521
|
+
return { state: j.state, id: j.id, compatibilityPathsRetained: true };
|
|
522
|
+
}
|
|
523
|
+
async checkRetained(c, j) {
|
|
524
|
+
if (await directoryIdentity(j.source) !== j.sourceIdentity)
|
|
525
|
+
fail('volume', 'Original source volume changed; rollback refused.');
|
|
526
|
+
if (j.backupIdentity)
|
|
527
|
+
await this.checkBackup(j);
|
|
528
|
+
const entries = [];
|
|
529
|
+
for (const tree of j.trees) {
|
|
530
|
+
const backupRoot = join(j.source, '.storage-retained', j.id), backup = join(backupRoot, tree);
|
|
531
|
+
if (await present(backup))
|
|
532
|
+
entries.push(...await inventory(backupRoot, [tree], true, c.signal, false));
|
|
533
|
+
else {
|
|
534
|
+
const st = await present(join(j.source, tree));
|
|
535
|
+
if (st && !st.isSymbolicLink())
|
|
536
|
+
entries.push(...await inventory(j.source, [tree], true, c.signal, false));
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (digest(content(entries)) !== j.manifest)
|
|
540
|
+
fail('verification', 'Retained source bytes changed; rollback refused. The destination remains retained.');
|
|
541
|
+
}
|
|
542
|
+
async checkBackup(j, allowAbsent = false) {
|
|
543
|
+
const root = join(j.source, '.storage-retained', j.id), parent = dirname(root);
|
|
544
|
+
if (!j.backupIdentity || !j.backupParentIdentity || await directoryIdentity(parent) !== j.backupParentIdentity || await canonicalFuture(root) !== root)
|
|
545
|
+
fail('binding', 'Retained source ancestry was replaced; no deletion or restore was attempted.');
|
|
546
|
+
if (!await present(root) && allowAbsent)
|
|
547
|
+
return;
|
|
548
|
+
if (await directoryIdentity(root) !== j.backupIdentity)
|
|
549
|
+
fail('binding', 'Retained source directory was replaced; no deletion or restore was attempted.');
|
|
550
|
+
}
|
|
551
|
+
commands() {
|
|
552
|
+
const directory = { flags: '--directory <path>', description: 'Absolute base directory; app name and profile are appended', required: true };
|
|
553
|
+
return command('storage', 'Inspect and migrate profile analysis data', [
|
|
554
|
+
command('show', 'Show effective analysis storage, usage and retained migration', (_i, c) => this.show(c), { permission: Permission.ReadOnly }),
|
|
555
|
+
command('configure', 'Choose storage for a profile with no analysis data', ({ options: o }, c) => this.migrate(c, o.directory, false, true), { permission: Permission.Update, options: [directory] }),
|
|
556
|
+
command('migrate', 'Copy and verify all analysis data, retain source, then switch storage', ({ options: o }, c) => this.migrate(c, o.directory, Boolean(o.dryRun)), { permission: Permission.Update, options: [directory, { flags: '--dry-run', description: 'Read-only path, capacity and quiescence preflight; create no migration' }] }),
|
|
557
|
+
command('resume', 'Resume the retained copy/activation or rollback without replaying research', (_i, c) => this.resume(c), { permission: Permission.Update }),
|
|
558
|
+
command('rollback', 'Restore retained source; refuse after any destination content changes', (_i, c) => this.rollback(c), { permission: Permission.Update }),
|
|
559
|
+
command('cleanup', 'Delete verified retained source copies; retain compatibility paths permanently', (_i, c) => this.cleanup(c), { permission: 'Destructive' }),
|
|
560
|
+
]);
|
|
561
|
+
}
|
|
562
|
+
}
|
package/dist/src/cli.js
CHANGED
|
@@ -51,9 +51,11 @@ import { artifactsCommands } from './saved-materials.js';
|
|
|
51
51
|
import { RuntimeManager, runtimeCommands, runtimeProcesses } from './runtime-manager.js';
|
|
52
52
|
import { packageVersion } from './package-version.js';
|
|
53
53
|
import { runtimeSelection } from './runtime-selection.js';
|
|
54
|
+
import { AnalysisStorage, analysisDirectory } from './analysis-storage.js';
|
|
54
55
|
const bindingFields = ['java', 'ghidraHome', 'mcpJar', 'mcpJarSha256', 'server', 'repository', 'readerUser', 'publisherUser', 'maxContexts', 'debuggerPython', 'windbgDir', 'archiveUrl'];
|
|
55
56
|
class Owner {
|
|
56
57
|
options;
|
|
58
|
+
storage;
|
|
57
59
|
profiles = new Map();
|
|
58
60
|
runtimeChanging = new Set();
|
|
59
61
|
async runtime(c, action, quiescent = true) {
|
|
@@ -88,10 +90,11 @@ class Owner {
|
|
|
88
90
|
const directory = c.appArguments.AppDataDirectory;
|
|
89
91
|
if (this.runtimeChanging.has(directory))
|
|
90
92
|
fail('busy', 'Runtime preparation is in progress.');
|
|
91
|
-
const
|
|
93
|
+
const root = analysisDirectory(c);
|
|
94
|
+
const configuration = digest({ analysisDirectory: root, runtime: runtimeSelection(directory, root), profile: Object.fromEntries(bindingFields.map(key => [key, c.profile.values[key]])), extensions: extensionState(join(root, 'workspace')).modules.filter(m => m.enabled).map(m => [m.name, m.sha256]) });
|
|
92
95
|
let profile = this.profiles.get(directory);
|
|
93
96
|
if (!profile) {
|
|
94
|
-
profile = { workspace: new Workspace(join(
|
|
97
|
+
profile = { workspace: new Workspace(join(root, 'workspace'), c.profile.name), tasks: new Map(), configuration };
|
|
95
98
|
this.profiles.set(directory, profile);
|
|
96
99
|
}
|
|
97
100
|
if (profile.configuration !== configuration)
|
|
@@ -116,6 +119,8 @@ class Owner {
|
|
|
116
119
|
}
|
|
117
120
|
async invalidateProfile(app) {
|
|
118
121
|
const directory = app.AppDataDirectory, profile = this.profiles.get(directory);
|
|
122
|
+
if (this.storage?.busy(directory))
|
|
123
|
+
fail('busy', 'Storage migration is in progress.');
|
|
119
124
|
if (this.runtimeChanging.has(directory))
|
|
120
125
|
fail('busy', 'Runtime preparation is in progress; finish it before changing or deleting the profile.');
|
|
121
126
|
if (toolProfileBusy(directory))
|
|
@@ -124,7 +129,7 @@ class Owner {
|
|
|
124
129
|
fail('busy', 'The profile has active or unverified tool processes.');
|
|
125
130
|
const materials = await readdir(join(directory, 'materials')).catch(error => { if (error.code === 'ENOENT')
|
|
126
131
|
return []; throw error; });
|
|
127
|
-
if (existsSync(join(directory, 'workspace', 'workspace.sqlite')) || materials.length)
|
|
132
|
+
if (existsSync(join(directory, 'storage', 'migration.json')) || existsSync(join(directory, 'workspace', 'workspace.sqlite')) || materials.length)
|
|
128
133
|
fail('blocked', 'Profile has retained research data; preserve its workspace and materials before deleting or reconfiguring it.');
|
|
129
134
|
if (profile) {
|
|
130
135
|
await (await profile.backend)?.dispose();
|
|
@@ -132,6 +137,16 @@ class Owner {
|
|
|
132
137
|
this.profiles.delete(directory);
|
|
133
138
|
}
|
|
134
139
|
}
|
|
140
|
+
async releaseStorage(c) {
|
|
141
|
+
const directory = c.appArguments.AppDataDirectory, profile = this.profiles.get(directory);
|
|
142
|
+
if (!profile)
|
|
143
|
+
return;
|
|
144
|
+
if (profile.tasks.size)
|
|
145
|
+
fail('busy', 'Research commands are active.');
|
|
146
|
+
await (await profile.backend)?.dispose();
|
|
147
|
+
profile.workspace.close();
|
|
148
|
+
this.profiles.delete(directory);
|
|
149
|
+
}
|
|
135
150
|
async dispose() {
|
|
136
151
|
for (const profile of this.profiles.values()) {
|
|
137
152
|
await Promise.allSettled([...profile.tasks.values()]);
|
|
@@ -260,6 +275,8 @@ function scopedFailures(entry, owner, parents = []) {
|
|
|
260
275
|
}
|
|
261
276
|
export function createDefinition(options = {}) {
|
|
262
277
|
const owner = new Owner(options);
|
|
278
|
+
const storage = new AnalysisStorage(options.runtime?.profileStore, c => owner.releaseStorage(c));
|
|
279
|
+
owner.storage = storage;
|
|
263
280
|
const definition = {
|
|
264
281
|
name: 'insight-cli', applicationId: 'insight-cli', version: packageVersion, description: 'Isolated research contexts and coordinated Ghidra analysis',
|
|
265
282
|
resources: [owner], permissions: { categories: [{ name: 'Destructive', description: 'Explicit deletion or replacement of retained research data', enabledByDefault: false }] }, ...(options.runtime ? { runtime: options.runtime } : {}),
|
|
@@ -483,8 +500,9 @@ export function createDefinition(options = {}) {
|
|
|
483
500
|
]),
|
|
484
501
|
],
|
|
485
502
|
};
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
503
|
+
const commands = definition.commands?.map(entry => entry.name === 'ghidra' ?
|
|
504
|
+
scopedFailures({ ...entry, children: extendQueries(extendQueries(extendQueries(entry.children ?? [], queryCommands(c => owner.selected(c).research)), mutationCommands(c => owner.selected(c).research)), [...sdkCommands(c => owner.selected(c).research), ...sdkFileCommands(c => owner.selected(c).sdkFiles), ...trackingCommands(c => owner.selected(c).tracking), ...crossCommands(c => owner.selected(c).research), ...administrationCommands(c => owner.selected(c).administration, options.backend ? undefined : async (c) => { const status = await new RuntimeManager(c).status(); return status.state === 'not_ready' ? { ...status.server, connected: false, state: 'runtime_not_ready', missing: status.missing } : undefined; }),
|
|
505
|
+
...extensionCommands(c => new Extensions(join(analysisDirectory(c), 'workspace'))), ...extensionInventoryCommands(c => owner.selected(c).backend), ...pluginCommands(c => owner.selected(c).ui), ...synchronizationCommands(c => owner.selected(c).synchronization), ...utilityCommands(), ...runtimeCommands((c, action, quiescent) => owner.runtime(c, action, quiescent)), ...lifecycleCommands(c => owner.selected(c).lifecycle, c => new RuntimeManager(c).status()), ...projectCommands(c => owner.selected(c).projects), ...uiCommands(c => owner.selected(c).ui), ...debuggerCommands(c => owner.selected(c).debugger), ...archiveCommands(c => owner.selected(c).archive)]) }, owner) : entry);
|
|
506
|
+
return { ...definition, commands: [...(commands ?? []).map(e => storage.wrap(e)), storage.commands()] };
|
|
489
507
|
}
|
|
490
508
|
export const createInsightCli = (options = {}) => createCli(createDefinition(options));
|