@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,124 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ProvisionError } from './errors.js';
|
|
5
|
+
import { fsyncDir, sha256File } from './fsutil.js';
|
|
6
|
+
import { isInside, safeEntrySegments, safeLinkTarget } from './paths.js';
|
|
7
|
+
import { readTarGz } from './tar.js';
|
|
8
|
+
import { readZip } from './zip.js';
|
|
9
|
+
function unsafe(item, what, name) {
|
|
10
|
+
return new ProvisionError('E_UNSAFE_PATH', `refusing archive entry (${what}): ${JSON.stringify(name)}`, { item });
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Extract an archive into `dest` (which must be empty or absent). Every entry is checked
|
|
14
|
+
* before anything touches the disk: no absolute paths, no `..`, no symlink that resolves
|
|
15
|
+
* outside `dest`, no write through a symlinked ancestor. Returns the ownership lists.
|
|
16
|
+
*/
|
|
17
|
+
export async function extractArchive(kind, archive, dest, opts) {
|
|
18
|
+
const { item } = opts;
|
|
19
|
+
const strip = opts.strip ?? 0;
|
|
20
|
+
const destAbs = path.resolve(dest);
|
|
21
|
+
await fs.mkdir(destAbs, { recursive: true });
|
|
22
|
+
const files = [];
|
|
23
|
+
const dirs = new Set();
|
|
24
|
+
const symlinks = [];
|
|
25
|
+
const symlinkPaths = new Set();
|
|
26
|
+
const filePaths = new Map(); // rel -> abs (for hardlinks)
|
|
27
|
+
let entries = 0;
|
|
28
|
+
const entryStream = kind === 'tar.gz' ? readTarGz(archive, item) : readZip(archive, item);
|
|
29
|
+
const ensureNoSymlinkAncestor = (segments, name) => {
|
|
30
|
+
for (let i = 1; i < segments.length; i++) {
|
|
31
|
+
if (symlinkPaths.has(segments.slice(0, i).join('/')))
|
|
32
|
+
throw unsafe(item, 'write through symlink', name);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const resolveDest = (segments, name) => {
|
|
36
|
+
const abs = path.join(destAbs, ...segments);
|
|
37
|
+
if (!isInside(destAbs, abs) || abs === destAbs)
|
|
38
|
+
throw unsafe(item, 'escapes destination', name);
|
|
39
|
+
return abs;
|
|
40
|
+
};
|
|
41
|
+
for await (const entry of entryStream) {
|
|
42
|
+
entries++;
|
|
43
|
+
const rawSegments = safeEntrySegments(entry.name);
|
|
44
|
+
if (rawSegments === null)
|
|
45
|
+
throw unsafe(item, 'absolute or traversal path', entry.name);
|
|
46
|
+
if (rawSegments.length <= strip) {
|
|
47
|
+
// Stripped away entirely (e.g. the top-level directory). Body drained by the reader.
|
|
48
|
+
for await (const _ of entry.body) {
|
|
49
|
+
/* drain */
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const segments = rawSegments.slice(strip);
|
|
54
|
+
const rel = segments.join('/');
|
|
55
|
+
ensureNoSymlinkAncestor(segments, entry.name);
|
|
56
|
+
const abs = resolveDest(segments, entry.name);
|
|
57
|
+
switch (entry.kind) {
|
|
58
|
+
case 'dir': {
|
|
59
|
+
await fs.mkdir(abs, { recursive: true });
|
|
60
|
+
dirs.add(rel);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case 'file': {
|
|
64
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
65
|
+
for (let i = 1; i < segments.length; i++)
|
|
66
|
+
dirs.add(segments.slice(0, i).join('/'));
|
|
67
|
+
const mode = (entry.mode & 0o777) || 0o644;
|
|
68
|
+
const handle = await fs.open(abs, 'wx', mode);
|
|
69
|
+
const hash = createHash('sha256');
|
|
70
|
+
let size = 0;
|
|
71
|
+
try {
|
|
72
|
+
for await (const chunk of entry.body) {
|
|
73
|
+
hash.update(chunk);
|
|
74
|
+
await handle.write(chunk);
|
|
75
|
+
size += chunk.length;
|
|
76
|
+
}
|
|
77
|
+
// No per-file fsync: it costs ~20 s for a Node tree on APFS and buys nothing against
|
|
78
|
+
// process termination (the level X02 proves). Power-loss corruption is caught by
|
|
79
|
+
// `verify` through the per-file digests in the ownership manifest.
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
await handle.close();
|
|
83
|
+
}
|
|
84
|
+
if (process.platform !== 'win32')
|
|
85
|
+
await fs.chmod(abs, mode);
|
|
86
|
+
files.push({ path: rel, size, sha256: hash.digest('hex'), mode });
|
|
87
|
+
filePaths.set(rel, abs);
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
case 'symlink': {
|
|
91
|
+
const target = entry.linkTarget ?? '';
|
|
92
|
+
const resolved = safeLinkTarget(segments, target);
|
|
93
|
+
/* MUTATION:symlink-escape */
|
|
94
|
+
if (resolved === null)
|
|
95
|
+
throw unsafe(item, `symlink escapes destination (-> ${target})`, entry.name);
|
|
96
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
97
|
+
for (let i = 1; i < segments.length; i++)
|
|
98
|
+
dirs.add(segments.slice(0, i).join('/'));
|
|
99
|
+
await fs.symlink(target.split(/[\\/]+/).join(path.sep), abs);
|
|
100
|
+
symlinks.push({ path: rel, target });
|
|
101
|
+
symlinkPaths.add(rel);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case 'hardlink': {
|
|
105
|
+
const targetSegments = safeEntrySegments(entry.linkTarget ?? '');
|
|
106
|
+
if (targetSegments === null || targetSegments.length <= strip)
|
|
107
|
+
throw unsafe(item, 'hardlink target', entry.name);
|
|
108
|
+
const targetRel = targetSegments.slice(strip).join('/');
|
|
109
|
+
const src = filePaths.get(targetRel);
|
|
110
|
+
if (src === undefined)
|
|
111
|
+
throw unsafe(item, 'hardlink to unknown file', entry.name);
|
|
112
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
113
|
+
await fs.copyFile(src, abs);
|
|
114
|
+
const st = await fs.stat(abs);
|
|
115
|
+
files.push({ path: rel, size: st.size, sha256: await sha256File(abs), mode: st.mode & 0o777 });
|
|
116
|
+
filePaths.set(rel, abs);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
opts.onProgress?.({ item, phase: 'extract', bytes: entries });
|
|
121
|
+
}
|
|
122
|
+
await fsyncDir(destAbs);
|
|
123
|
+
return { files, dirs: [...dirs].sort(), symlinks };
|
|
124
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function sha256File(file: string): Promise<string>;
|
|
2
|
+
/** temp + fsync + rename. The data is durable and the path is never observed half-written. */
|
|
3
|
+
export declare function writeFileAtomic(file: string, data: string | Uint8Array, mode?: number): Promise<void>;
|
|
4
|
+
/** Best-effort directory fsync (no-op where the platform refuses to open directories). */
|
|
5
|
+
export declare function fsyncDir(dir: string): Promise<void>;
|
|
6
|
+
export declare function exists(p: string): Promise<boolean>;
|
|
7
|
+
export declare function readJson<T>(file: string): Promise<T | undefined>;
|
|
8
|
+
export declare function rmrf(p: string): Promise<void>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
export async function sha256File(file) {
|
|
7
|
+
const hash = createHash('sha256');
|
|
8
|
+
await pipeline(createReadStream(file), hash);
|
|
9
|
+
return hash.digest('hex');
|
|
10
|
+
}
|
|
11
|
+
/** temp + fsync + rename. The data is durable and the path is never observed half-written. */
|
|
12
|
+
export async function writeFileAtomic(file, data, mode) {
|
|
13
|
+
const dir = path.dirname(file);
|
|
14
|
+
await fs.mkdir(dir, { recursive: true });
|
|
15
|
+
const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now().toString(36)}.tmp`);
|
|
16
|
+
const handle = await fs.open(tmp, 'w', mode ?? 0o644);
|
|
17
|
+
try {
|
|
18
|
+
await handle.writeFile(data);
|
|
19
|
+
await handle.sync();
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
await handle.close();
|
|
23
|
+
}
|
|
24
|
+
await fs.rename(tmp, file);
|
|
25
|
+
await fsyncDir(dir);
|
|
26
|
+
}
|
|
27
|
+
/** Best-effort directory fsync (no-op where the platform refuses to open directories). */
|
|
28
|
+
export async function fsyncDir(dir) {
|
|
29
|
+
if (process.platform === 'win32')
|
|
30
|
+
return;
|
|
31
|
+
let handle;
|
|
32
|
+
try {
|
|
33
|
+
handle = await fs.open(dir, 'r');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
await handle.sync();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* some filesystems reject fsync on directories; the rename itself is still atomic */
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await handle.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function exists(p) {
|
|
49
|
+
try {
|
|
50
|
+
await fs.lstat(p);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export async function readJson(file) {
|
|
58
|
+
let text;
|
|
59
|
+
try {
|
|
60
|
+
text = await fs.readFile(file, 'utf8');
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (err.code === 'ENOENT')
|
|
64
|
+
return undefined;
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
return JSON.parse(text);
|
|
68
|
+
}
|
|
69
|
+
export async function rmrf(p) {
|
|
70
|
+
await fs.rm(p, { recursive: true, force: true, maxRetries: 3 });
|
|
71
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { platformKey } from './platform.js';
|
|
2
|
+
export { plan, provision, remove, verify } from './provision.js';
|
|
3
|
+
export { ledger, ledgerPath, ownershipPath, targetPath } from './ledger.js';
|
|
4
|
+
export { validateManifest } from './manifest.js';
|
|
5
|
+
export { ProvisionError, isProvisionError, type ProvisionErrorCode } from './errors.js';
|
|
6
|
+
export { stripVerbatimPrefix, toVerbatimPath, externalPath, isInside, safeEntrySegments, safeLinkTarget, redactUrl } from './paths.js';
|
|
7
|
+
export type * from './types.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { platformKey } from './platform.js';
|
|
2
|
+
export { plan, provision, remove, verify } from './provision.js';
|
|
3
|
+
export { ledger, ledgerPath, ownershipPath, targetPath } from './ledger.js';
|
|
4
|
+
export { validateManifest } from './manifest.js';
|
|
5
|
+
export { ProvisionError, isProvisionError } from './errors.js';
|
|
6
|
+
export { stripVerbatimPrefix, toVerbatimPath, externalPath, isInside, safeEntrySegments, safeLinkTarget, redactUrl } from './paths.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Ledger, OwnershipManifest } from './types.js';
|
|
2
|
+
export declare const PROVISION_DIR = ".provision";
|
|
3
|
+
export declare function provisionDir(root: string): string;
|
|
4
|
+
export declare function ledgerPath(root: string): string;
|
|
5
|
+
export declare function stagingDir(root: string, item: string, sha256: string): string;
|
|
6
|
+
export declare function trashRoot(root: string): string;
|
|
7
|
+
export declare function pendingDir(root: string): string;
|
|
8
|
+
export declare function targetPath(root: string, installTo: string): string;
|
|
9
|
+
export declare function ownershipPath(target: string): string;
|
|
10
|
+
/** Read `<root>/.provision/ledger.json`; an absent ledger is an empty one. */
|
|
11
|
+
export declare function ledger(root: string): Promise<Ledger>;
|
|
12
|
+
/** Atomic (temp + fsync + rename) ledger write. Single writer by contract. */
|
|
13
|
+
export declare function writeLedger(root: string, data: Ledger): Promise<void>;
|
|
14
|
+
export declare function readOwnership(target: string): Promise<OwnershipManifest | undefined>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { ProvisionError } from './errors.js';
|
|
3
|
+
import { readJson, writeFileAtomic } from './fsutil.js';
|
|
4
|
+
import { externalPath } from './paths.js';
|
|
5
|
+
export const PROVISION_DIR = '.provision';
|
|
6
|
+
export function provisionDir(root) {
|
|
7
|
+
return path.join(externalPath(root), PROVISION_DIR);
|
|
8
|
+
}
|
|
9
|
+
export function ledgerPath(root) {
|
|
10
|
+
return path.join(provisionDir(root), 'ledger.json');
|
|
11
|
+
}
|
|
12
|
+
export function stagingDir(root, item, sha256) {
|
|
13
|
+
return path.join(provisionDir(root), 'staging', `${item}-${sha256.slice(0, 12)}`);
|
|
14
|
+
}
|
|
15
|
+
export function trashRoot(root) {
|
|
16
|
+
return path.join(provisionDir(root), 'trash');
|
|
17
|
+
}
|
|
18
|
+
export function pendingDir(root) {
|
|
19
|
+
return path.join(provisionDir(root), 'pending');
|
|
20
|
+
}
|
|
21
|
+
export function targetPath(root, installTo) {
|
|
22
|
+
return path.join(externalPath(root), ...installTo.split('/'));
|
|
23
|
+
}
|
|
24
|
+
export function ownershipPath(target) {
|
|
25
|
+
return target + '.manifest.json';
|
|
26
|
+
}
|
|
27
|
+
/** Read `<root>/.provision/ledger.json`; an absent ledger is an empty one. */
|
|
28
|
+
export async function ledger(root) {
|
|
29
|
+
const data = await readJson(ledgerPath(root));
|
|
30
|
+
if (data === undefined)
|
|
31
|
+
return { schema: 1, items: {} };
|
|
32
|
+
if (typeof data !== 'object' || data === null || data.schema !== 1 || typeof data.items !== 'object') {
|
|
33
|
+
throw new ProvisionError('E_LEDGER', `ledger corrupt: ${ledgerPath(root)}`);
|
|
34
|
+
}
|
|
35
|
+
return data;
|
|
36
|
+
}
|
|
37
|
+
/** Atomic (temp + fsync + rename) ledger write. Single writer by contract. */
|
|
38
|
+
export async function writeLedger(root, data) {
|
|
39
|
+
await writeFileAtomic(ledgerPath(root), JSON.stringify(data, null, 2) + '\n');
|
|
40
|
+
}
|
|
41
|
+
export async function readOwnership(target) {
|
|
42
|
+
const data = await readJson(ownershipPath(target));
|
|
43
|
+
if (data === undefined)
|
|
44
|
+
return undefined;
|
|
45
|
+
const m = data;
|
|
46
|
+
if (m.schema !== 1 || typeof m.item !== 'string' || !Array.isArray(m.files)) {
|
|
47
|
+
throw new ProvisionError('E_LEDGER', `ownership manifest corrupt: ${ownershipPath(target)}`);
|
|
48
|
+
}
|
|
49
|
+
return m;
|
|
50
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Manifest, PlatformAsset } from './types.js';
|
|
2
|
+
/** Only `https:` and `file:` are ever accepted, anywhere a URL appears. */
|
|
3
|
+
export declare function assertAllowedUrl(u: string, what: string): URL;
|
|
4
|
+
/** Validate an untrusted manifest object. Throws `ProvisionError('E_MANIFEST')`. */
|
|
5
|
+
export declare function validateManifest(raw: unknown): Manifest;
|
|
6
|
+
/** Candidate download URLs for one asset, in the order they must be tried. */
|
|
7
|
+
export declare function resolveCandidates(manifest: Manifest, asset: PlatformAsset, sourceOrder?: string[]): Array<{
|
|
8
|
+
source: string;
|
|
9
|
+
kind: 'https' | 'file';
|
|
10
|
+
url: URL;
|
|
11
|
+
}>;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { ProvisionError } from './errors.js';
|
|
2
|
+
import { redactUrl } from './paths.js';
|
|
3
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
4
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
5
|
+
function fail(msg, item) {
|
|
6
|
+
throw new ProvisionError('E_MANIFEST', msg, item === undefined ? undefined : { item });
|
|
7
|
+
}
|
|
8
|
+
function isRecord(v) {
|
|
9
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
10
|
+
}
|
|
11
|
+
/** Only `https:` and `file:` are ever accepted, anywhere a URL appears. */
|
|
12
|
+
export function assertAllowedUrl(u, what) {
|
|
13
|
+
let url;
|
|
14
|
+
try {
|
|
15
|
+
url = new URL(u);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
fail(`${what}: not a valid URL: ${redactUrl(u)}`);
|
|
19
|
+
}
|
|
20
|
+
if (url.protocol !== 'https:' && url.protocol !== 'file:') {
|
|
21
|
+
fail(`${what}: only https: and file: are accepted, got ${url.protocol} (${redactUrl(u)})`);
|
|
22
|
+
}
|
|
23
|
+
return url;
|
|
24
|
+
}
|
|
25
|
+
function validateSource(raw, index) {
|
|
26
|
+
if (!isRecord(raw))
|
|
27
|
+
fail(`sources[${index}] must be an object`);
|
|
28
|
+
const { id, kind, base } = raw;
|
|
29
|
+
if (typeof id !== 'string' || !ID_RE.test(id))
|
|
30
|
+
fail(`sources[${index}].id invalid`);
|
|
31
|
+
if (kind !== 'https' && kind !== 'file')
|
|
32
|
+
fail(`sources[${index}].kind must be "https" or "file"`);
|
|
33
|
+
if (typeof base !== 'string')
|
|
34
|
+
fail(`sources[${index}].base must be a string`);
|
|
35
|
+
const url = assertAllowedUrl(base, `sources[${index}].base`);
|
|
36
|
+
if ((kind === 'https' && url.protocol !== 'https:') || (kind === 'file' && url.protocol !== 'file:')) {
|
|
37
|
+
fail(`sources[${index}]: kind "${kind}" does not match base protocol ${url.protocol}`);
|
|
38
|
+
}
|
|
39
|
+
return { id, kind, base };
|
|
40
|
+
}
|
|
41
|
+
function validatePlatformAsset(raw, itemId, key) {
|
|
42
|
+
if (!isRecord(raw))
|
|
43
|
+
fail(`items[${itemId}].platforms[${key}] must be an object`, itemId);
|
|
44
|
+
const { asset, sha256, size, strip, url } = raw;
|
|
45
|
+
if (typeof asset !== 'string' || asset.length === 0)
|
|
46
|
+
fail(`items[${itemId}].platforms[${key}].asset required`, itemId);
|
|
47
|
+
if (asset.startsWith('/') || asset.includes('..'))
|
|
48
|
+
fail(`items[${itemId}].platforms[${key}].asset must be a relative name`, itemId);
|
|
49
|
+
/* MUTATION:manifest-sha256-required */
|
|
50
|
+
if (typeof sha256 !== 'string' || !SHA256_RE.test(sha256)) {
|
|
51
|
+
fail(`items[${itemId}].platforms[${key}].sha256 missing or not 64 lower-case hex chars — refusing to guess`, itemId);
|
|
52
|
+
}
|
|
53
|
+
const out = { asset, sha256 };
|
|
54
|
+
if (size !== undefined) {
|
|
55
|
+
if (typeof size !== 'number' || !Number.isInteger(size) || size < 0)
|
|
56
|
+
fail(`items[${itemId}].platforms[${key}].size invalid`, itemId);
|
|
57
|
+
out.size = size;
|
|
58
|
+
}
|
|
59
|
+
if (strip !== undefined) {
|
|
60
|
+
if (typeof strip !== 'number' || !Number.isInteger(strip) || strip < 0)
|
|
61
|
+
fail(`items[${itemId}].platforms[${key}].strip invalid`, itemId);
|
|
62
|
+
out.strip = strip;
|
|
63
|
+
}
|
|
64
|
+
if (url !== undefined) {
|
|
65
|
+
if (typeof url !== 'string')
|
|
66
|
+
fail(`items[${itemId}].platforms[${key}].url must be a string`, itemId);
|
|
67
|
+
assertAllowedUrl(url, `items[${itemId}].platforms[${key}].url`);
|
|
68
|
+
out.url = url;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
function validateItem(raw, index, seen) {
|
|
73
|
+
if (!isRecord(raw))
|
|
74
|
+
fail(`items[${index}] must be an object`);
|
|
75
|
+
const { id, version, kind, installTo, platforms, verify } = raw;
|
|
76
|
+
if (typeof id !== 'string' || !ID_RE.test(id))
|
|
77
|
+
fail(`items[${index}].id invalid`);
|
|
78
|
+
if (seen.has(id))
|
|
79
|
+
fail(`duplicate item id "${id}"`, id);
|
|
80
|
+
seen.add(id);
|
|
81
|
+
if (typeof version !== 'string' || version.length === 0)
|
|
82
|
+
fail(`items[${id}].version required`, id);
|
|
83
|
+
if (kind !== 'tar.gz' && kind !== 'zip')
|
|
84
|
+
fail(`items[${id}].kind must be "tar.gz" or "zip" (.tar.xz is not supported)`, id);
|
|
85
|
+
if (typeof installTo !== 'string' || installTo.length === 0)
|
|
86
|
+
fail(`items[${id}].installTo required`, id);
|
|
87
|
+
const segs = installTo.split('/').filter((s) => s.length > 0);
|
|
88
|
+
if (segs.length === 0 || installTo.startsWith('/') || /^[A-Za-z]:/.test(installTo) || installTo.includes('\\') || segs.some((s) => s === '..' || s === '.')) {
|
|
89
|
+
fail(`items[${id}].installTo must be a relative POSIX path inside root`, id);
|
|
90
|
+
}
|
|
91
|
+
if (segs[0] === '.provision')
|
|
92
|
+
fail(`items[${id}].installTo may not live under .provision`, id);
|
|
93
|
+
if (!isRecord(platforms))
|
|
94
|
+
fail(`items[${id}].platforms must be an object`, id);
|
|
95
|
+
const outPlatforms = {};
|
|
96
|
+
for (const [key, val] of Object.entries(platforms))
|
|
97
|
+
outPlatforms[key] = validatePlatformAsset(val, id, key);
|
|
98
|
+
const item = { id, version, kind, installTo: segs.join('/'), platforms: outPlatforms };
|
|
99
|
+
if (verify !== undefined) {
|
|
100
|
+
if (!isRecord(verify))
|
|
101
|
+
fail(`items[${id}].verify must be an object`, id);
|
|
102
|
+
const { exec, args, expect, timeoutMs } = verify;
|
|
103
|
+
if (typeof exec !== 'string' || exec.length === 0 || exec.startsWith('/') || exec.includes('..'))
|
|
104
|
+
fail(`items[${id}].verify.exec must be a relative path inside the tree`, id);
|
|
105
|
+
const v = { exec };
|
|
106
|
+
if (args !== undefined) {
|
|
107
|
+
if (!Array.isArray(args) || !args.every((a) => typeof a === 'string'))
|
|
108
|
+
fail(`items[${id}].verify.args must be string[]`, id);
|
|
109
|
+
v.args = args;
|
|
110
|
+
}
|
|
111
|
+
if (expect !== undefined) {
|
|
112
|
+
if (typeof expect !== 'string')
|
|
113
|
+
fail(`items[${id}].verify.expect must be a string`, id);
|
|
114
|
+
v.expect = expect;
|
|
115
|
+
}
|
|
116
|
+
if (timeoutMs !== undefined) {
|
|
117
|
+
if (typeof timeoutMs !== 'number' || timeoutMs <= 0)
|
|
118
|
+
fail(`items[${id}].verify.timeoutMs invalid`, id);
|
|
119
|
+
v.timeoutMs = timeoutMs;
|
|
120
|
+
}
|
|
121
|
+
item.verify = v;
|
|
122
|
+
}
|
|
123
|
+
return item;
|
|
124
|
+
}
|
|
125
|
+
/** Validate an untrusted manifest object. Throws `ProvisionError('E_MANIFEST')`. */
|
|
126
|
+
export function validateManifest(raw) {
|
|
127
|
+
if (!isRecord(raw))
|
|
128
|
+
fail('manifest must be an object');
|
|
129
|
+
if (raw['schema'] !== 1)
|
|
130
|
+
fail('manifest.schema must be 1');
|
|
131
|
+
if (!Array.isArray(raw['sources']))
|
|
132
|
+
fail('manifest.sources must be an array');
|
|
133
|
+
if (!Array.isArray(raw['items']))
|
|
134
|
+
fail('manifest.items must be an array');
|
|
135
|
+
const sourceIds = new Set();
|
|
136
|
+
const sources = raw['sources'].map((s, i) => {
|
|
137
|
+
const src = validateSource(s, i);
|
|
138
|
+
if (sourceIds.has(src.id))
|
|
139
|
+
fail(`duplicate source id "${src.id}"`);
|
|
140
|
+
sourceIds.add(src.id);
|
|
141
|
+
return src;
|
|
142
|
+
});
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
const items = raw['items'].map((it, i) => validateItem(it, i, seen));
|
|
145
|
+
return { schema: 1, sources, items };
|
|
146
|
+
}
|
|
147
|
+
/** Candidate download URLs for one asset, in the order they must be tried. */
|
|
148
|
+
export function resolveCandidates(manifest, asset, sourceOrder) {
|
|
149
|
+
if (asset.url !== undefined) {
|
|
150
|
+
const url = assertAllowedUrl(asset.url, 'asset.url');
|
|
151
|
+
return [{ source: 'url', kind: url.protocol === 'file:' ? 'file' : 'https', url }];
|
|
152
|
+
}
|
|
153
|
+
let sources = manifest.sources;
|
|
154
|
+
if (sourceOrder !== undefined) {
|
|
155
|
+
const byId = new Map(sources.map((s) => [s.id, s]));
|
|
156
|
+
sources = sourceOrder.map((id) => {
|
|
157
|
+
const s = byId.get(id);
|
|
158
|
+
if (s === undefined)
|
|
159
|
+
throw new ProvisionError('E_SOURCE', `unknown source id "${id}"`);
|
|
160
|
+
return s;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (sources.length === 0)
|
|
164
|
+
throw new ProvisionError('E_SOURCE', 'no sources available for asset');
|
|
165
|
+
return sources.map((s) => {
|
|
166
|
+
const base = s.base.endsWith('/') ? s.base : s.base + '/';
|
|
167
|
+
const url = new URL(asset.asset, base);
|
|
168
|
+
assertAllowedUrl(url.toString(), `source ${s.id}`);
|
|
169
|
+
return { source: s.id, kind: s.kind, url };
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** `\\?\C:\x` → `C:\x`; `\\?\UNC\srv\share` → `\\srv\share`. No-op for other paths. */
|
|
2
|
+
export declare function stripVerbatimPrefix(p: string): string;
|
|
3
|
+
/** Inverse of {@link stripVerbatimPrefix} for absolute Windows paths (used only for opening long paths). */
|
|
4
|
+
export declare function toVerbatimPath(p: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* The form of a path this library exposes to callers: absolute, normalised,
|
|
7
|
+
* and on Windows never carrying the `\\?\` prefix (which breaks PATH search and
|
|
8
|
+
* string comparison in every consumer that gets it).
|
|
9
|
+
*/
|
|
10
|
+
export declare function externalPath(p: string, platform?: NodeJS.Platform): string;
|
|
11
|
+
/** True when `child` is `parent` or lies strictly inside it (lexically, after resolution). */
|
|
12
|
+
export declare function isInside(parent: string, child: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Split an archive entry name into safe path segments, or return null when the
|
|
15
|
+
* entry must be refused (absolute, drive-qualified, `..`, NUL, empty).
|
|
16
|
+
* Both `/` and `\` are treated as separators because zip writers disagree.
|
|
17
|
+
*/
|
|
18
|
+
export declare function safeEntrySegments(name: string): string[] | null;
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a symlink target relative to the entry's directory and check that it
|
|
21
|
+
* stays inside the tree. Returns the normalised relative POSIX target path of the
|
|
22
|
+
* link destination, or null when the link would escape.
|
|
23
|
+
*/
|
|
24
|
+
export declare function safeLinkTarget(entrySegments: string[], target: string): string | null;
|
|
25
|
+
/** Redact the query/fragment of a URL for logs (tokens live there). */
|
|
26
|
+
export declare function redactUrl(u: string): string;
|
|
27
|
+
export declare const toPosix: (p: string) => string;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
/** `\\?\C:\x` → `C:\x`; `\\?\UNC\srv\share` → `\\srv\share`. No-op for other paths. */
|
|
3
|
+
export function stripVerbatimPrefix(p) {
|
|
4
|
+
if (p.startsWith('\\\\?\\UNC\\'))
|
|
5
|
+
return '\\\\' + p.slice('\\\\?\\UNC\\'.length);
|
|
6
|
+
if (p.startsWith('\\\\?\\'))
|
|
7
|
+
return p.slice('\\\\?\\'.length);
|
|
8
|
+
return p;
|
|
9
|
+
}
|
|
10
|
+
/** Inverse of {@link stripVerbatimPrefix} for absolute Windows paths (used only for opening long paths). */
|
|
11
|
+
export function toVerbatimPath(p) {
|
|
12
|
+
if (p.startsWith('\\\\?\\'))
|
|
13
|
+
return p;
|
|
14
|
+
if (/^[A-Za-z]:\\/.test(p))
|
|
15
|
+
return '\\\\?\\' + p;
|
|
16
|
+
if (p.startsWith('\\\\'))
|
|
17
|
+
return '\\\\?\\UNC\\' + p.slice(2);
|
|
18
|
+
return p;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The form of a path this library exposes to callers: absolute, normalised,
|
|
22
|
+
* and on Windows never carrying the `\\?\` prefix (which breaks PATH search and
|
|
23
|
+
* string comparison in every consumer that gets it).
|
|
24
|
+
*/
|
|
25
|
+
export function externalPath(p, platform = process.platform) {
|
|
26
|
+
const stripped = platform === 'win32' ? stripVerbatimPrefix(p) : p;
|
|
27
|
+
return path.resolve(stripped);
|
|
28
|
+
}
|
|
29
|
+
/** True when `child` is `parent` or lies strictly inside it (lexically, after resolution). */
|
|
30
|
+
export function isInside(parent, child) {
|
|
31
|
+
const rel = path.relative(path.resolve(parent), path.resolve(child));
|
|
32
|
+
if (rel === '')
|
|
33
|
+
return true;
|
|
34
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
35
|
+
}
|
|
36
|
+
const WINDOWS_ABS = /^[A-Za-z]:([\\/]|$)/;
|
|
37
|
+
/**
|
|
38
|
+
* Split an archive entry name into safe path segments, or return null when the
|
|
39
|
+
* entry must be refused (absolute, drive-qualified, `..`, NUL, empty).
|
|
40
|
+
* Both `/` and `\` are treated as separators because zip writers disagree.
|
|
41
|
+
*/
|
|
42
|
+
export function safeEntrySegments(name) {
|
|
43
|
+
if (name.length === 0 || name.includes('\0'))
|
|
44
|
+
return null;
|
|
45
|
+
if (name.startsWith('/') || name.startsWith('\\'))
|
|
46
|
+
return null;
|
|
47
|
+
if (WINDOWS_ABS.test(name))
|
|
48
|
+
return null;
|
|
49
|
+
const segments = name.split(/[\\/]+/).filter((s) => s.length > 0 && s !== '.');
|
|
50
|
+
/* MUTATION:path-traversal */
|
|
51
|
+
if (segments.some((s) => s === '..'))
|
|
52
|
+
return null;
|
|
53
|
+
return segments;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a symlink target relative to the entry's directory and check that it
|
|
57
|
+
* stays inside the tree. Returns the normalised relative POSIX target path of the
|
|
58
|
+
* link destination, or null when the link would escape.
|
|
59
|
+
*/
|
|
60
|
+
export function safeLinkTarget(entrySegments, target) {
|
|
61
|
+
if (target.length === 0 || target.includes('\0'))
|
|
62
|
+
return null;
|
|
63
|
+
if (target.startsWith('/') || target.startsWith('\\') || WINDOWS_ABS.test(target))
|
|
64
|
+
return null;
|
|
65
|
+
const dir = entrySegments.slice(0, -1);
|
|
66
|
+
const parts = target.split(/[\\/]+/).filter((s) => s.length > 0 && s !== '.');
|
|
67
|
+
const stack = [...dir];
|
|
68
|
+
for (const part of parts) {
|
|
69
|
+
if (part === '..') {
|
|
70
|
+
if (stack.length === 0)
|
|
71
|
+
return null;
|
|
72
|
+
stack.pop();
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
stack.push(part);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return stack.join('/');
|
|
79
|
+
}
|
|
80
|
+
/** Redact the query/fragment of a URL for logs (tokens live there). */
|
|
81
|
+
export function redactUrl(u) {
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(u);
|
|
84
|
+
url.search = '';
|
|
85
|
+
url.hash = '';
|
|
86
|
+
url.username = '';
|
|
87
|
+
url.password = '';
|
|
88
|
+
return url.toString();
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
const q = u.search(/[?#]/);
|
|
92
|
+
return q === -1 ? u : u.slice(0, q);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export const toPosix = (p) => p.split(path.sep).join('/');
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PlatformKey } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Normalised platform key. Only `process.platform` and `process.arch` are consulted;
|
|
4
|
+
* nothing installed on the host is ever probed.
|
|
5
|
+
*/
|
|
6
|
+
export declare function platformKey(platform?: NodeJS.Platform, arch?: string): PlatformKey;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PlanEntry, PlanOptions, ProvisionOptions, ProvisionResult, RemoveResult, VerifyResult } from './types.js';
|
|
2
|
+
/** Pure planning against the ledger: what would `provision` do for each item. */
|
|
3
|
+
export declare function plan(manifestInput: unknown, options: PlanOptions): Promise<PlanEntry[]>;
|
|
4
|
+
/** Download → sha256 → extract → check → atomic promote → ledger, item by item. */
|
|
5
|
+
export declare function provision(manifestInput: unknown, options: ProvisionOptions): Promise<ProvisionResult[]>;
|
|
6
|
+
/** Delete exactly what the ownership manifest lists; anything else in the target is left alone. */
|
|
7
|
+
export declare function remove(itemId: string, options: {
|
|
8
|
+
root: string;
|
|
9
|
+
}): Promise<RemoveResult>;
|
|
10
|
+
/**
|
|
11
|
+
* Recompute every installed item's digests against its ownership manifest and reconcile the
|
|
12
|
+
* ledger with what is really on disk. Repairs the state left by a kill between
|
|
13
|
+
* `rename(tree → target)` and the ledger write, and restores an old tree left in trash.
|
|
14
|
+
*/
|
|
15
|
+
export declare function verify(options: {
|
|
16
|
+
root: string;
|
|
17
|
+
}): Promise<VerifyResult>;
|