@hanamesh/dsh-app-host 0.2.0-rc.2
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/LICENSE +31 -0
- package/README.md +104 -0
- package/consistency.json +101 -0
- package/dist/client-ui.d.ts +19 -0
- package/dist/client-ui.js +271 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.js +42 -0
- package/dist/consistency.js +12 -0
- package/dist/descriptor.js +141 -0
- package/dist/dsh.d.ts +25 -0
- package/dist/dsh.js +194 -0
- package/dist/errors.js +25 -0
- package/dist/gateway.js +224 -0
- package/dist/guardian.js +113 -0
- package/dist/index.d.ts +130 -0
- package/dist/index.js +15 -0
- package/dist/launcher.js +17 -0
- package/dist/library/catalog.js +108 -0
- package/dist/library/domain.js +8 -0
- package/dist/library/install.js +87 -0
- package/dist/library/installed.js +67 -0
- package/dist/library/locate.js +79 -0
- package/dist/library/routes.js +17 -0
- package/dist/library/service.js +76 -0
- package/dist/manager.js +605 -0
- package/dist/provision/LICENSE +6 -0
- package/dist/provision/THIRD_PARTY_NOTICES.md +13 -0
- package/dist/provision/archive.d.ts +28 -0
- package/dist/provision/archive.js +66 -0
- package/dist/provision/check.d.ts +10 -0
- package/dist/provision/check.js +51 -0
- package/dist/provision/checkpoints.d.ts +12 -0
- package/dist/provision/checkpoints.js +23 -0
- package/dist/provision/cli.d.ts +1 -0
- package/dist/provision/cli.js +169 -0
- package/dist/provision/download.d.ts +21 -0
- package/dist/provision/download.js +242 -0
- package/dist/provision/errors.d.ts +11 -0
- package/dist/provision/errors.js +13 -0
- package/dist/provision/extract.d.ts +17 -0
- package/dist/provision/extract.js +124 -0
- package/dist/provision/fsutil.d.ts +8 -0
- package/dist/provision/fsutil.js +71 -0
- package/dist/provision/index.d.ts +7 -0
- package/dist/provision/index.js +6 -0
- package/dist/provision/ledger.d.ts +14 -0
- package/dist/provision/ledger.js +50 -0
- package/dist/provision/manifest.d.ts +11 -0
- package/dist/provision/manifest.js +171 -0
- package/dist/provision/paths.d.ts +27 -0
- package/dist/provision/paths.js +95 -0
- package/dist/provision/platform.d.ts +6 -0
- package/dist/provision/platform.js +7 -0
- package/dist/provision/provision.d.ts +17 -0
- package/dist/provision/provision.js +454 -0
- package/dist/provision/tar.d.ts +7 -0
- package/dist/provision/tar.js +169 -0
- package/dist/provision/types.d.ts +174 -0
- package/dist/provision/types.js +7 -0
- package/dist/provision/zip.d.ts +4 -0
- package/dist/provision/zip.js +194 -0
- package/dist/router/broker.js +161 -0
- package/dist/router/domain.js +18 -0
- package/dist/router/receipts-domain.js +8 -0
- package/dist/router/receipts.js +156 -0
- package/dist/router/routes.js +48 -0
- package/dist/router/sources.js +87 -0
- package/dist/routes.js +74 -0
- package/dist/runtime.js +222 -0
- package/dist/store.js +250 -0
- package/dist/usage-evidence.js +117 -0
- package/docs/APP_PACKAGE.md +38 -0
- package/docs/CONTRACT.md +190 -0
- package/docs/DSH_INTEGRATION.md +43 -0
- package/docs/LIBRARY.md +45 -0
- package/docs/LICENSES.json +185 -0
- package/docs/PROVENANCE.json +63 -0
- package/docs/SECURITY.md +52 -0
- package/package.json +112 -0
- package/profile/cordis.patch.yml +4 -0
- package/schemas/catalog-provider-page.schema.json +376 -0
- package/schemas/catalog-source.schema.json +151 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { runCheck } from './check.js';
|
|
4
|
+
import { checkpoint } from './checkpoints.js';
|
|
5
|
+
import { downloadVerified } from './download.js';
|
|
6
|
+
import { ProvisionError } from './errors.js';
|
|
7
|
+
import { extractArchive } from './extract.js';
|
|
8
|
+
import { exists, fsyncDir, readJson, rmrf, sha256File, writeFileAtomic } from './fsutil.js';
|
|
9
|
+
import { ledger, ownershipPath, pendingDir, provisionDir, readOwnership, stagingDir, targetPath, trashRoot, writeLedger } from './ledger.js';
|
|
10
|
+
import { resolveCandidates, validateManifest } from './manifest.js';
|
|
11
|
+
import { externalPath, isInside } from './paths.js';
|
|
12
|
+
import { platformKey } from './platform.js';
|
|
13
|
+
function selectItems(manifest, only) {
|
|
14
|
+
if (only === undefined)
|
|
15
|
+
return manifest.items;
|
|
16
|
+
const byId = new Map(manifest.items.map((i) => [i.id, i]));
|
|
17
|
+
return only.map((id) => {
|
|
18
|
+
const it = byId.get(id);
|
|
19
|
+
if (it === undefined)
|
|
20
|
+
throw new ProvisionError('E_MANIFEST', `unknown item id "${id}"`, { item: id });
|
|
21
|
+
return it;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** Pure planning against the ledger: what would `provision` do for each item. */
|
|
25
|
+
export async function plan(manifestInput, options) {
|
|
26
|
+
const manifest = validateManifest(manifestInput);
|
|
27
|
+
const root = externalPath(options.root);
|
|
28
|
+
const platform = options.platform ?? platformKey();
|
|
29
|
+
const book = await ledger(root);
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const item of selectItems(manifest, options.only)) {
|
|
32
|
+
const target = targetPath(root, item.installTo);
|
|
33
|
+
const asset = item.platforms[platform];
|
|
34
|
+
const current = book.items[item.id];
|
|
35
|
+
if (asset === undefined) {
|
|
36
|
+
const e = { item: item.id, version: item.version, platform, target, state: 'unsupported' };
|
|
37
|
+
if (current !== undefined)
|
|
38
|
+
e.current = current;
|
|
39
|
+
out.push(e);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
let state = 'absent';
|
|
43
|
+
if (current !== undefined)
|
|
44
|
+
state = current.sha256 === asset.sha256 && current.platform === platform && (await exists(target)) ? 'installed' : 'stale';
|
|
45
|
+
const e = { item: item.id, version: item.version, platform, asset: asset.asset, sha256: asset.sha256, target, state };
|
|
46
|
+
if (current !== undefined)
|
|
47
|
+
e.current = current;
|
|
48
|
+
out.push(e);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/** Download → sha256 → extract → check → atomic promote → ledger, item by item. */
|
|
53
|
+
export async function provision(manifestInput, options) {
|
|
54
|
+
const manifest = validateManifest(manifestInput);
|
|
55
|
+
const root = externalPath(options.root);
|
|
56
|
+
const platform = options.platform ?? platformKey();
|
|
57
|
+
await fs.mkdir(provisionDir(root), { recursive: true });
|
|
58
|
+
const results = [];
|
|
59
|
+
for (const item of selectItems(manifest, options.only)) {
|
|
60
|
+
if (options.signal?.aborted)
|
|
61
|
+
throw new ProvisionError('E_ABORTED', 'aborted by caller', { item: item.id });
|
|
62
|
+
const target = targetPath(root, item.installTo);
|
|
63
|
+
const asset = item.platforms[platform];
|
|
64
|
+
if (asset === undefined) {
|
|
65
|
+
results.push({ item: item.id, version: item.version, platform, target, action: 'unsupported' });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const book = await ledger(root);
|
|
69
|
+
const current = book.items[item.id];
|
|
70
|
+
if (!options.force && current !== undefined && current.sha256 === asset.sha256 && current.platform === platform && (await exists(target))) {
|
|
71
|
+
results.push({ item: item.id, version: item.version, platform, target, action: 'skipped', sha256: asset.sha256, source: current.source });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const source = await provisionItem(manifest, item, asset, root, platform, options);
|
|
75
|
+
results.push({ item: item.id, version: item.version, platform, target, action: 'installed', sha256: asset.sha256, source });
|
|
76
|
+
}
|
|
77
|
+
return results;
|
|
78
|
+
}
|
|
79
|
+
async function provisionItem(manifest, item, asset, root, platform, options) {
|
|
80
|
+
const staging = stagingDir(root, item.id, asset.sha256);
|
|
81
|
+
if (!isInside(root, staging))
|
|
82
|
+
throw new ProvisionError('E_PROMOTE', 'staging escapes root', { item: item.id });
|
|
83
|
+
const archive = path.join(staging, path.basename(asset.asset));
|
|
84
|
+
const tree = path.join(staging, 'tree');
|
|
85
|
+
const target = targetPath(root, item.installTo);
|
|
86
|
+
const onProgress = options.onProgress;
|
|
87
|
+
await fs.mkdir(staging, { recursive: true });
|
|
88
|
+
await rmrf(tree); // a half-extracted tree from a killed run is never trusted
|
|
89
|
+
try {
|
|
90
|
+
const candidates = resolveCandidates(manifest, asset, options.sourceOrder);
|
|
91
|
+
const source = await downloadVerified(candidates, {
|
|
92
|
+
item: item.id,
|
|
93
|
+
file: archive,
|
|
94
|
+
expectedSha256: asset.sha256,
|
|
95
|
+
expectedSize: asset.size,
|
|
96
|
+
onProgress,
|
|
97
|
+
signal: options.signal,
|
|
98
|
+
});
|
|
99
|
+
onProgress?.({ item: item.id, phase: 'extract' });
|
|
100
|
+
const extracted = await extractArchive(item.kind, archive, tree, { item: item.id, strip: asset.strip, onProgress });
|
|
101
|
+
checkpoint('after-extract');
|
|
102
|
+
if (item.verify !== undefined) {
|
|
103
|
+
onProgress?.({ item: item.id, phase: 'check' });
|
|
104
|
+
await runCheck(tree, item.verify, item.id);
|
|
105
|
+
checkpoint('after-check');
|
|
106
|
+
}
|
|
107
|
+
const ownership = {
|
|
108
|
+
schema: 1,
|
|
109
|
+
item: item.id,
|
|
110
|
+
version: item.version,
|
|
111
|
+
platform,
|
|
112
|
+
sha256: asset.sha256,
|
|
113
|
+
source,
|
|
114
|
+
installTo: item.installTo,
|
|
115
|
+
files: extracted.files,
|
|
116
|
+
dirs: extracted.dirs,
|
|
117
|
+
symlinks: extracted.symlinks,
|
|
118
|
+
};
|
|
119
|
+
onProgress?.({ item: item.id, phase: 'promote' });
|
|
120
|
+
await promote(root, item, ownership, tree, target, options.force === true);
|
|
121
|
+
await rmrf(staging);
|
|
122
|
+
return source;
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
const keepPartial = err instanceof ProvisionError && (err.code === 'E_DOWNLOAD' || err.code === 'E_ABORTED');
|
|
126
|
+
if (keepPartial)
|
|
127
|
+
await rmrf(tree);
|
|
128
|
+
else
|
|
129
|
+
await rmrf(staging);
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Atomic promotion. Order (each step durable before the next):
|
|
135
|
+
* pending record → ownership manifest (temp) → old target+manifest to trash →
|
|
136
|
+
* manifest temp → final → rename(tree → target) → ledger → drop trash + pending.
|
|
137
|
+
* Any failure before the tree rename restores the old target from trash.
|
|
138
|
+
*/
|
|
139
|
+
async function promote(root, item, ownership, tree, target, force) {
|
|
140
|
+
const manifestFile = ownershipPath(target);
|
|
141
|
+
const manifestNext = manifestFile + '.next';
|
|
142
|
+
const pending = path.join(pendingDir(root), `${item.id}.json`);
|
|
143
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
144
|
+
const trash = path.join(trashRoot(root), `${stamp}-${item.id}`);
|
|
145
|
+
const oldTarget = path.join(trash, 'tree');
|
|
146
|
+
const oldManifest = path.join(trash, 'manifest.json');
|
|
147
|
+
const targetExists = await exists(target);
|
|
148
|
+
const oldOwnership = await readOwnership(target);
|
|
149
|
+
if (targetExists && oldOwnership === undefined && !force) {
|
|
150
|
+
throw new ProvisionError('E_PROMOTE', `target exists but has no ownership manifest; refusing to replace unmanaged data at ${target} (use force)`, { item: item.id });
|
|
151
|
+
}
|
|
152
|
+
if (!isInside(root, target))
|
|
153
|
+
throw new ProvisionError('E_PROMOTE', 'target escapes root', { item: item.id });
|
|
154
|
+
const record = { item: item.id, installTo: item.installTo, version: item.version, sha256: ownership.sha256, startedAt: new Date().toISOString() };
|
|
155
|
+
await writeFileAtomic(pending, JSON.stringify(record));
|
|
156
|
+
await writeFileAtomic(manifestNext, JSON.stringify(ownership, null, 2) + '\n');
|
|
157
|
+
checkpoint('after-manifest-temp');
|
|
158
|
+
let movedTarget = false;
|
|
159
|
+
let movedManifest = false;
|
|
160
|
+
try {
|
|
161
|
+
if (targetExists || (await exists(manifestFile))) {
|
|
162
|
+
await fs.mkdir(trash, { recursive: true });
|
|
163
|
+
if (targetExists) {
|
|
164
|
+
await fs.rename(target, oldTarget);
|
|
165
|
+
movedTarget = true;
|
|
166
|
+
}
|
|
167
|
+
if (await exists(manifestFile)) {
|
|
168
|
+
await fs.rename(manifestFile, oldManifest);
|
|
169
|
+
movedManifest = true;
|
|
170
|
+
}
|
|
171
|
+
await fsyncDir(path.dirname(target));
|
|
172
|
+
checkpoint('after-trash');
|
|
173
|
+
}
|
|
174
|
+
await fs.rename(manifestNext, manifestFile);
|
|
175
|
+
await fsyncDir(path.dirname(manifestFile));
|
|
176
|
+
/* MUTATION:ledger-before-rename */
|
|
177
|
+
checkpoint('after-manifest');
|
|
178
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
179
|
+
try {
|
|
180
|
+
await fs.rename(tree, target);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
const code = err.code;
|
|
184
|
+
throw new ProvisionError('E_PROMOTE', code === 'EXDEV' ? `staging and target are on different devices; refusing to copy (${target})` : `rename to target failed: ${err.message}`, { item: item.id, cause: err });
|
|
185
|
+
}
|
|
186
|
+
await fsyncDir(path.dirname(target));
|
|
187
|
+
checkpoint('after-rename');
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
// Restore the previous state: new manifest out, old tree/manifest back.
|
|
191
|
+
await fs.rm(manifestNext, { force: true });
|
|
192
|
+
await fs.rm(manifestFile, { force: true }).catch(() => undefined);
|
|
193
|
+
if (movedTarget)
|
|
194
|
+
await fs.rename(oldTarget, target).catch(() => undefined);
|
|
195
|
+
if (movedManifest)
|
|
196
|
+
await fs.rename(oldManifest, manifestFile).catch(() => undefined);
|
|
197
|
+
await rmrf(trash);
|
|
198
|
+
await fs.rm(pending, { force: true });
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
const book = await ledger(root);
|
|
202
|
+
const entry = {
|
|
203
|
+
version: item.version,
|
|
204
|
+
sha256: ownership.sha256,
|
|
205
|
+
platform: ownership.platform,
|
|
206
|
+
installedAt: new Date().toISOString(),
|
|
207
|
+
target,
|
|
208
|
+
installTo: item.installTo,
|
|
209
|
+
source: ownership.source,
|
|
210
|
+
};
|
|
211
|
+
book.items[item.id] = entry;
|
|
212
|
+
await writeLedger(root, book);
|
|
213
|
+
checkpoint('after-ledger');
|
|
214
|
+
await rmrf(trash);
|
|
215
|
+
await fs.rm(pending, { force: true });
|
|
216
|
+
}
|
|
217
|
+
/** Delete exactly what the ownership manifest lists; anything else in the target is left alone. */
|
|
218
|
+
export async function remove(itemId, options) {
|
|
219
|
+
const root = externalPath(options.root);
|
|
220
|
+
const book = await ledger(root);
|
|
221
|
+
const entry = book.items[itemId];
|
|
222
|
+
if (entry === undefined)
|
|
223
|
+
throw new ProvisionError('E_NOT_INSTALLED', `item "${itemId}" is not in the ledger`, { item: itemId });
|
|
224
|
+
const target = targetPath(root, entry.installTo);
|
|
225
|
+
if (!isInside(root, target))
|
|
226
|
+
throw new ProvisionError('E_PROMOTE', 'target escapes root', { item: itemId });
|
|
227
|
+
const ownership = await readOwnership(target);
|
|
228
|
+
if (ownership === undefined) {
|
|
229
|
+
throw new ProvisionError('E_NOT_INSTALLED', `ownership manifest missing for "${itemId}" (${ownershipPath(target)}); refusing to delete anything — run verify`, { item: itemId });
|
|
230
|
+
}
|
|
231
|
+
let removedFiles = 0;
|
|
232
|
+
let removedSymlinks = 0;
|
|
233
|
+
let removedDirs = 0;
|
|
234
|
+
const unlink = async (rel) => {
|
|
235
|
+
const abs = path.join(target, ...rel.split('/'));
|
|
236
|
+
if (!isInside(target, abs))
|
|
237
|
+
return false;
|
|
238
|
+
try {
|
|
239
|
+
await fs.unlink(abs);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
if (err.code === 'ENOENT')
|
|
244
|
+
return false;
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
for (const f of ownership.files)
|
|
249
|
+
if (await unlink(f.path))
|
|
250
|
+
removedFiles++;
|
|
251
|
+
for (const s of ownership.symlinks)
|
|
252
|
+
if (await unlink(s.path))
|
|
253
|
+
removedSymlinks++;
|
|
254
|
+
const dirs = [...ownership.dirs].sort((a, b) => b.length - a.length);
|
|
255
|
+
for (const d of dirs) {
|
|
256
|
+
const abs = path.join(target, ...d.split('/'));
|
|
257
|
+
if (!isInside(target, abs))
|
|
258
|
+
continue;
|
|
259
|
+
try {
|
|
260
|
+
await fs.rmdir(abs);
|
|
261
|
+
removedDirs++;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
/* not empty (something we do not own) or already gone */
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
await fs.rmdir(target);
|
|
269
|
+
removedDirs++;
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
/* leftovers we do not own */
|
|
273
|
+
}
|
|
274
|
+
const kept = await listTree(target);
|
|
275
|
+
await fs.rm(ownershipPath(target), { force: true });
|
|
276
|
+
delete book.items[itemId];
|
|
277
|
+
await writeLedger(root, book);
|
|
278
|
+
return { item: itemId, target, removedFiles, removedSymlinks, removedDirs, kept };
|
|
279
|
+
}
|
|
280
|
+
async function listTree(dir) {
|
|
281
|
+
const out = [];
|
|
282
|
+
const walk = async (d, rel) => {
|
|
283
|
+
let entries;
|
|
284
|
+
try {
|
|
285
|
+
entries = await fs.readdir(d, { withFileTypes: true });
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
for (const e of entries) {
|
|
291
|
+
const r = rel === '' ? e.name : `${rel}/${e.name}`;
|
|
292
|
+
if (e.isDirectory())
|
|
293
|
+
await walk(path.join(d, e.name), r);
|
|
294
|
+
else
|
|
295
|
+
out.push(r);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
await walk(dir, '');
|
|
299
|
+
return out.sort();
|
|
300
|
+
}
|
|
301
|
+
async function checkTree(target, m) {
|
|
302
|
+
for (const f of m.files) {
|
|
303
|
+
const abs = path.join(target, ...f.path.split('/'));
|
|
304
|
+
let st;
|
|
305
|
+
try {
|
|
306
|
+
st = await fs.lstat(abs);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return `missing file ${f.path}`;
|
|
310
|
+
}
|
|
311
|
+
if (!st.isFile())
|
|
312
|
+
return `not a regular file: ${f.path}`;
|
|
313
|
+
if (st.size !== f.size)
|
|
314
|
+
return `size mismatch ${f.path}: ${st.size} != ${f.size}`;
|
|
315
|
+
if ((await sha256File(abs)) !== f.sha256)
|
|
316
|
+
return `sha256 mismatch ${f.path}`;
|
|
317
|
+
}
|
|
318
|
+
for (const s of m.symlinks) {
|
|
319
|
+
const abs = path.join(target, ...s.path.split('/'));
|
|
320
|
+
try {
|
|
321
|
+
const st = await fs.lstat(abs);
|
|
322
|
+
if (!st.isSymbolicLink())
|
|
323
|
+
return `not a symlink: ${s.path}`;
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
return `missing symlink ${s.path}`;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Recompute every installed item's digests against its ownership manifest and reconcile the
|
|
333
|
+
* ledger with what is really on disk. Repairs the state left by a kill between
|
|
334
|
+
* `rename(tree → target)` and the ledger write, and restores an old tree left in trash.
|
|
335
|
+
*/
|
|
336
|
+
export async function verify(options) {
|
|
337
|
+
const root = externalPath(options.root);
|
|
338
|
+
const book = await ledger(root);
|
|
339
|
+
const items = [];
|
|
340
|
+
const cleaned = [];
|
|
341
|
+
const pdir = pendingDir(root);
|
|
342
|
+
const pendings = new Map();
|
|
343
|
+
try {
|
|
344
|
+
for (const name of await fs.readdir(pdir)) {
|
|
345
|
+
const rec = await readJson(path.join(pdir, name));
|
|
346
|
+
if (rec !== undefined && typeof rec.installTo === 'string')
|
|
347
|
+
pendings.set(rec.item, rec);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
/* no pending dir */
|
|
352
|
+
}
|
|
353
|
+
const trashDirs = new Map();
|
|
354
|
+
try {
|
|
355
|
+
for (const name of await fs.readdir(trashRoot(root))) {
|
|
356
|
+
const m = /^\d{4}-\d{2}-\d{2}T[\d-]+Z-(.+)$/.exec(name);
|
|
357
|
+
if (m === null)
|
|
358
|
+
continue;
|
|
359
|
+
const list = trashDirs.get(m[1]) ?? [];
|
|
360
|
+
list.push(path.join(trashRoot(root), name));
|
|
361
|
+
trashDirs.set(m[1], list);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
/* no trash */
|
|
366
|
+
}
|
|
367
|
+
const ids = new Set([...Object.keys(book.items), ...pendings.keys()]);
|
|
368
|
+
let ledgerDirty = false;
|
|
369
|
+
for (const id of [...ids].sort()) {
|
|
370
|
+
const entry = book.items[id];
|
|
371
|
+
const installTo = entry?.installTo ?? pendings.get(id).installTo;
|
|
372
|
+
const target = targetPath(root, installTo);
|
|
373
|
+
const manifestFile = ownershipPath(target);
|
|
374
|
+
const next = manifestFile + '.next';
|
|
375
|
+
if (await exists(next)) {
|
|
376
|
+
await fs.rm(next, { force: true });
|
|
377
|
+
cleaned.push(next);
|
|
378
|
+
}
|
|
379
|
+
const trashes = (trashDirs.get(id) ?? []).sort();
|
|
380
|
+
let targetExists = await exists(target);
|
|
381
|
+
if (!targetExists) {
|
|
382
|
+
// A kill between "old to trash" and "tree to target": bring the old version back.
|
|
383
|
+
const last = trashes.at(-1);
|
|
384
|
+
if (last !== undefined && (await exists(path.join(last, 'tree')))) {
|
|
385
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
386
|
+
await fs.rename(path.join(last, 'tree'), target);
|
|
387
|
+
await fs.rm(manifestFile, { force: true });
|
|
388
|
+
if (await exists(path.join(last, 'manifest.json')))
|
|
389
|
+
await fs.rename(path.join(last, 'manifest.json'), manifestFile);
|
|
390
|
+
await fsyncDir(path.dirname(target));
|
|
391
|
+
cleaned.push(`${last} (restored)`);
|
|
392
|
+
targetExists = true;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
let ownership;
|
|
396
|
+
try {
|
|
397
|
+
ownership = await readOwnership(target);
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
items.push({ item: id, target, state: 'corrupt', detail: err.message });
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (!targetExists) {
|
|
404
|
+
if (ownership !== undefined) {
|
|
405
|
+
await fs.rm(manifestFile, { force: true });
|
|
406
|
+
cleaned.push(manifestFile);
|
|
407
|
+
}
|
|
408
|
+
if (entry !== undefined) {
|
|
409
|
+
delete book.items[id];
|
|
410
|
+
ledgerDirty = true;
|
|
411
|
+
}
|
|
412
|
+
items.push({ item: id, target, state: 'missing', detail: entry === undefined ? 'pending install never landed' : 'target gone; ledger entry dropped' });
|
|
413
|
+
}
|
|
414
|
+
else if (ownership === undefined) {
|
|
415
|
+
items.push({ item: id, target, state: 'unmanaged', detail: 'target exists without an ownership manifest; nothing touched' });
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
const problem = await checkTree(target, ownership);
|
|
420
|
+
if (problem !== undefined) {
|
|
421
|
+
items.push({ item: id, target, state: 'corrupt', detail: problem, version: ownership.version, sha256: ownership.sha256 });
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (entry === undefined || entry.sha256 !== ownership.sha256 || entry.version !== ownership.version || entry.platform !== ownership.platform || entry.installTo !== ownership.installTo) {
|
|
425
|
+
book.items[id] = {
|
|
426
|
+
version: ownership.version,
|
|
427
|
+
sha256: ownership.sha256,
|
|
428
|
+
platform: ownership.platform,
|
|
429
|
+
installedAt: entry?.installedAt ?? new Date().toISOString(),
|
|
430
|
+
target,
|
|
431
|
+
installTo: ownership.installTo,
|
|
432
|
+
source: ownership.source,
|
|
433
|
+
};
|
|
434
|
+
ledgerDirty = true;
|
|
435
|
+
items.push({ item: id, target, state: 'repaired', detail: entry === undefined ? 'ledger entry written from ownership manifest' : 'ledger entry updated from ownership manifest', version: ownership.version, sha256: ownership.sha256 });
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
items.push({ item: id, target, state: 'ok', version: ownership.version, sha256: ownership.sha256 });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
for (const t of trashes) {
|
|
442
|
+
await rmrf(t);
|
|
443
|
+
cleaned.push(t);
|
|
444
|
+
}
|
|
445
|
+
if (pendings.has(id)) {
|
|
446
|
+
await fs.rm(path.join(pdir, `${id}.json`), { force: true });
|
|
447
|
+
cleaned.push(`pending:${id}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (ledgerDirty)
|
|
451
|
+
await writeLedger(root, book);
|
|
452
|
+
const ok = items.every((i) => i.state === 'ok' || i.state === 'repaired');
|
|
453
|
+
return { root, items, ok, cleaned };
|
|
454
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ArchiveEntry } from './archive.js';
|
|
2
|
+
/**
|
|
3
|
+
* Minimal streaming ustar/GNU/pax reader over a gzip stream. Supports regular files,
|
|
4
|
+
* directories, symlinks, hardlinks, GNU long names/links and pax path/linkpath/size.
|
|
5
|
+
* Anything else is refused with `E_ARCHIVE`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function readTarGz(file: string, item: string): AsyncGenerator<ArchiveEntry>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { createGunzip } from 'node:zlib';
|
|
3
|
+
import { ByteReader } from './archive.js';
|
|
4
|
+
import { ProvisionError } from './errors.js';
|
|
5
|
+
const BLOCK = 512;
|
|
6
|
+
function cstr(buf, off, len) {
|
|
7
|
+
const slice = buf.subarray(off, off + len);
|
|
8
|
+
const nul = slice.indexOf(0);
|
|
9
|
+
return Buffer.from(nul === -1 ? slice : slice.subarray(0, nul)).toString('utf8');
|
|
10
|
+
}
|
|
11
|
+
function octal(buf, off, len) {
|
|
12
|
+
const raw = buf.subarray(off, off + len);
|
|
13
|
+
if ((raw[0] & 0x80) !== 0) {
|
|
14
|
+
// GNU base-256 encoding for large numbers
|
|
15
|
+
let v = 0;
|
|
16
|
+
for (let i = 1; i < raw.length; i++)
|
|
17
|
+
v = v * 256 + raw[i];
|
|
18
|
+
return v;
|
|
19
|
+
}
|
|
20
|
+
const s = cstr(buf, off, len).trim();
|
|
21
|
+
return s.length === 0 ? 0 : parseInt(s, 8);
|
|
22
|
+
}
|
|
23
|
+
function checksumOk(header) {
|
|
24
|
+
let sum = 0;
|
|
25
|
+
for (let i = 0; i < BLOCK; i++)
|
|
26
|
+
sum += i >= 148 && i < 156 ? 0x20 : header[i];
|
|
27
|
+
return sum === octal(header, 148, 8);
|
|
28
|
+
}
|
|
29
|
+
function parsePax(text) {
|
|
30
|
+
const out = {};
|
|
31
|
+
let pos = 0;
|
|
32
|
+
while (pos < text.length) {
|
|
33
|
+
const sp = text.indexOf(' ', pos);
|
|
34
|
+
if (sp === -1)
|
|
35
|
+
break;
|
|
36
|
+
const len = Number(text.slice(pos, sp));
|
|
37
|
+
if (!Number.isFinite(len) || len <= 0)
|
|
38
|
+
break;
|
|
39
|
+
const record = text.slice(sp + 1, pos + len - 1);
|
|
40
|
+
const eq = record.indexOf('=');
|
|
41
|
+
if (eq !== -1)
|
|
42
|
+
out[record.slice(0, eq)] = record.slice(eq + 1);
|
|
43
|
+
pos += len;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
async function readAll(it) {
|
|
48
|
+
const parts = [];
|
|
49
|
+
for await (const c of it)
|
|
50
|
+
parts.push(c);
|
|
51
|
+
return Buffer.concat(parts);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Minimal streaming ustar/GNU/pax reader over a gzip stream. Supports regular files,
|
|
55
|
+
* directories, symlinks, hardlinks, GNU long names/links and pax path/linkpath/size.
|
|
56
|
+
* Anything else is refused with `E_ARCHIVE`.
|
|
57
|
+
*/
|
|
58
|
+
export async function* readTarGz(file, item) {
|
|
59
|
+
const gunzip = createGunzip();
|
|
60
|
+
const source = createReadStream(file, { highWaterMark: 1 << 20 }).pipe(gunzip);
|
|
61
|
+
const reader = new ByteReader(source);
|
|
62
|
+
let longName;
|
|
63
|
+
let longLink;
|
|
64
|
+
let pax = {};
|
|
65
|
+
let globalPax = {};
|
|
66
|
+
let sawZero = false;
|
|
67
|
+
for (;;) {
|
|
68
|
+
const header = await reader.read(BLOCK);
|
|
69
|
+
if (header.length === 0)
|
|
70
|
+
break;
|
|
71
|
+
if (header.length < BLOCK)
|
|
72
|
+
throw new ProvisionError('E_ARCHIVE', 'truncated tar header', { item });
|
|
73
|
+
if (header.every((b) => b === 0)) {
|
|
74
|
+
if (sawZero)
|
|
75
|
+
break;
|
|
76
|
+
sawZero = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
sawZero = false;
|
|
80
|
+
if (!checksumOk(header))
|
|
81
|
+
throw new ProvisionError('E_ARCHIVE', 'tar header checksum mismatch', { item });
|
|
82
|
+
let name = cstr(header, 0, 100);
|
|
83
|
+
const mode = octal(header, 100, 8) & 0o7777;
|
|
84
|
+
let size = octal(header, 124, 12);
|
|
85
|
+
const typeflag = String.fromCharCode(header[156]);
|
|
86
|
+
let linkTarget = cstr(header, 157, 100);
|
|
87
|
+
const magic = cstr(header, 257, 6);
|
|
88
|
+
if (magic === 'ustar') {
|
|
89
|
+
const prefix = cstr(header, 345, 155);
|
|
90
|
+
if (prefix.length > 0)
|
|
91
|
+
name = prefix + '/' + name;
|
|
92
|
+
}
|
|
93
|
+
const padded = Math.ceil(size / BLOCK) * BLOCK;
|
|
94
|
+
if (typeflag === 'L') {
|
|
95
|
+
longName = (await readAll(reader.stream(size))).toString('utf8').replace(/\0+$/, '');
|
|
96
|
+
await reader.skip(padded - size);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (typeflag === 'K') {
|
|
100
|
+
longLink = (await readAll(reader.stream(size))).toString('utf8').replace(/\0+$/, '');
|
|
101
|
+
await reader.skip(padded - size);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (typeflag === 'x') {
|
|
105
|
+
pax = parsePax((await readAll(reader.stream(size))).toString('utf8'));
|
|
106
|
+
await reader.skip(padded - size);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (typeflag === 'g') {
|
|
110
|
+
globalPax = parsePax((await readAll(reader.stream(size))).toString('utf8'));
|
|
111
|
+
await reader.skip(padded - size);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const merged = { ...globalPax, ...pax };
|
|
115
|
+
if (longName !== undefined)
|
|
116
|
+
name = longName;
|
|
117
|
+
if (merged['path'] !== undefined)
|
|
118
|
+
name = merged['path'];
|
|
119
|
+
if (longLink !== undefined)
|
|
120
|
+
linkTarget = longLink;
|
|
121
|
+
if (merged['linkpath'] !== undefined)
|
|
122
|
+
linkTarget = merged['linkpath'];
|
|
123
|
+
if (merged['size'] !== undefined)
|
|
124
|
+
size = Number(merged['size']);
|
|
125
|
+
longName = undefined;
|
|
126
|
+
longLink = undefined;
|
|
127
|
+
pax = {};
|
|
128
|
+
const bodyPadded = Math.ceil(size / BLOCK) * BLOCK;
|
|
129
|
+
let kind;
|
|
130
|
+
switch (typeflag) {
|
|
131
|
+
case '0':
|
|
132
|
+
case '\0':
|
|
133
|
+
case '7':
|
|
134
|
+
kind = 'file';
|
|
135
|
+
break;
|
|
136
|
+
case '5':
|
|
137
|
+
kind = 'dir';
|
|
138
|
+
break;
|
|
139
|
+
case '2':
|
|
140
|
+
kind = 'symlink';
|
|
141
|
+
break;
|
|
142
|
+
case '1':
|
|
143
|
+
kind = 'hardlink';
|
|
144
|
+
break;
|
|
145
|
+
default:
|
|
146
|
+
throw new ProvisionError('E_ARCHIVE', `unsupported tar entry type "${typeflag}" for ${name}`, { item });
|
|
147
|
+
}
|
|
148
|
+
if (kind === 'dir' && name.endsWith('/'))
|
|
149
|
+
name = name.slice(0, -1);
|
|
150
|
+
let consumed = false;
|
|
151
|
+
const body = (async function* () {
|
|
152
|
+
consumed = true;
|
|
153
|
+
if (kind === 'file')
|
|
154
|
+
yield* reader.stream(size);
|
|
155
|
+
else
|
|
156
|
+
await reader.skip(size);
|
|
157
|
+
await reader.skip(bodyPadded - size);
|
|
158
|
+
})();
|
|
159
|
+
const entry = { kind, name, mode, size: kind === 'file' ? size : 0, body };
|
|
160
|
+
if (kind === 'symlink' || kind === 'hardlink')
|
|
161
|
+
entry.linkTarget = linkTarget;
|
|
162
|
+
yield entry;
|
|
163
|
+
if (!consumed) {
|
|
164
|
+
for await (const _ of body) {
|
|
165
|
+
/* drain so the stream stays aligned */
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|