@hoardodile/host 0.1.0 → 0.1.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/dist/hoard/index.d.ts +21 -24
- package/dist/hoard/index.js +51 -43
- package/dist/hoard/index.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +52 -25
- package/dist/index.js.map +1 -1
- package/dist/pack-6IscrwYN.d.ts +35 -0
- package/package.json +3 -3
- package/src/archive/archive-ops.test.ts +49 -1
- package/src/archive/extract.ts +15 -0
- package/src/archive/index.ts +1 -1
- package/src/archive/pack.ts +50 -0
- package/src/hoard/paths.test.ts +1 -0
- package/src/hoard/paths.ts +9 -0
- package/src/index.ts +1 -0
- package/src/sandbox/fixtures/asset-plugin.mjs +8 -3
- package/src/sandbox/host.test.ts +26 -3
- package/src/sandbox/host.ts +15 -8
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* On-the-fly zip exports. Resource sources are stored as bare files on
|
|
5
|
+
* disk — nothing is packed at commit time; this module streams STORED
|
|
6
|
+
* zip bytes straight to the consumer without a staging file.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* A logical zip entry whose bytes are produced on demand. Streams are
|
|
10
|
+
* read once, in order, when the output is consumed.
|
|
11
|
+
*/
|
|
12
|
+
type ZipStreamEntry = {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly size: number;
|
|
15
|
+
readonly openStream: () => Readable;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Stream a STORED zip from an ordered list of logical entries. Used by
|
|
19
|
+
* the HTTP layer for exports (resource source packs, bulk downloads)
|
|
20
|
+
* without a staging file on disk. Zero-length entries are packed as
|
|
21
|
+
* empty buffers.
|
|
22
|
+
*/
|
|
23
|
+
declare function streamStoredZip(entries: readonly ZipStreamEntry[]): NodeJS.ReadableStream;
|
|
24
|
+
/**
|
|
25
|
+
* Pack the *contents* of `srcDir` (its entries at the archive root) into
|
|
26
|
+
* a deflated zip written to `zipPath`. Entry names use forward slashes,
|
|
27
|
+
* the file order is sorted by relative path, and the output is streamed
|
|
28
|
+
* to disk — deterministic on every platform, memory-bounded regardless
|
|
29
|
+
* of dist size. The canonical packer for project-internal zips (the
|
|
30
|
+
* plugin release artifact via the CLI); resource exports use
|
|
31
|
+
* {@link streamStoredZip} instead.
|
|
32
|
+
*/
|
|
33
|
+
declare function packZipDirectory(srcDir: string, zipPath: string): Promise<void>;
|
|
34
|
+
|
|
35
|
+
export { type ZipStreamEntry as Z, packZipDirectory as p, streamStoredZip as s };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hoardodile/host",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "hoardodile plugin runtime host: sandbox, hook strategy, ResourceAPI, containers and the test runner.",
|
|
6
6
|
"keywords": [
|
|
@@ -67,11 +67,11 @@
|
|
|
67
67
|
"file-type": "^22.0.2",
|
|
68
68
|
"yauzl": "^3.4.0",
|
|
69
69
|
"yazl": "^3.3.1",
|
|
70
|
-
"@hoardodile/sdk-types": "0.1.
|
|
70
|
+
"@hoardodile/sdk-types": "0.1.2"
|
|
71
71
|
},
|
|
72
72
|
"optionalDependencies": {
|
|
73
73
|
"@derhuerst/ffprobe-static": "^5.3.0",
|
|
74
|
-
"@hoardodile/7z-bin": "^1.1.
|
|
74
|
+
"@hoardodile/7z-bin": "^1.1.3",
|
|
75
75
|
"ffmpeg-static": "^5.3.0"
|
|
76
76
|
},
|
|
77
77
|
"peerDependencies": {
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
normalizeExtractedTree,
|
|
15
15
|
} from "./extract.ts"
|
|
16
16
|
import { listArchiveEntries, validateArchiveBudget } from "./listing.ts"
|
|
17
|
-
import { streamStoredZip } from "./pack.ts"
|
|
17
|
+
import { packZipDirectory, streamStoredZip } from "./pack.ts"
|
|
18
18
|
import {
|
|
19
19
|
createFileArchiveSource,
|
|
20
20
|
listZipEntries,
|
|
@@ -232,6 +232,30 @@ describe("extractArchiveInto", () => {
|
|
|
232
232
|
).rejects.toMatchObject({ kind: "resource.archive_open_failed" })
|
|
233
233
|
})
|
|
234
234
|
|
|
235
|
+
test("rejects formats outside the allow-list without touching the destination", async () => {
|
|
236
|
+
const root = tempRoot()
|
|
237
|
+
const dest = join(root, "out")
|
|
238
|
+
await expect(
|
|
239
|
+
extractArchiveInto(
|
|
240
|
+
Readable.from(await buildZip([["a.txt", "alpha"]])),
|
|
241
|
+
dest,
|
|
242
|
+
{ maxBytes: 1_000_000, formats: ["tar"] },
|
|
243
|
+
),
|
|
244
|
+
).rejects.toMatchObject({ kind: "resource.archive_format_not_allowed" })
|
|
245
|
+
expect(existsSync(join(dest, "a.txt"))).toBe(false)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test("accepts a zip under a zip-only allow-list", async () => {
|
|
249
|
+
const root = tempRoot()
|
|
250
|
+
const dest = join(root, "out")
|
|
251
|
+
await extractArchiveInto(
|
|
252
|
+
Readable.from(await buildZip([["a.txt", "alpha"]])),
|
|
253
|
+
dest,
|
|
254
|
+
{ maxBytes: 1_000_000, formats: ["zip"] },
|
|
255
|
+
)
|
|
256
|
+
expect(await readFile(join(dest, "a.txt"), "utf8")).toBe("alpha")
|
|
257
|
+
})
|
|
258
|
+
|
|
235
259
|
test("rejects a corrupt zip with the archive-open error", async () => {
|
|
236
260
|
const root = tempRoot()
|
|
237
261
|
const dest = join(root, "out")
|
|
@@ -575,6 +599,30 @@ describe("streamStoredZip", () => {
|
|
|
575
599
|
})
|
|
576
600
|
})
|
|
577
601
|
|
|
602
|
+
describe("packZipDirectory", () => {
|
|
603
|
+
test("packs a directory's contents at the zip root, sorted, with forward slashes", async () => {
|
|
604
|
+
const root = tempRoot()
|
|
605
|
+
const src = join(root, "src")
|
|
606
|
+
await mkdir(join(src, "nested"), { recursive: true })
|
|
607
|
+
await writeFile(join(src, "manifest.json"), '{"id":"x"}')
|
|
608
|
+
await writeFile(join(src, "a.txt"), "a")
|
|
609
|
+
await writeFile(join(src, "nested", "b.txt"), "b")
|
|
610
|
+
const zipPath = join(root, "out.zip")
|
|
611
|
+
|
|
612
|
+
await packZipDirectory(src, zipPath)
|
|
613
|
+
|
|
614
|
+
const entries = await listZipEntries(zipPath)
|
|
615
|
+
expect(entries.map((entry) => entry.name)).toEqual([
|
|
616
|
+
"a.txt",
|
|
617
|
+
"manifest.json",
|
|
618
|
+
"nested/b.txt",
|
|
619
|
+
])
|
|
620
|
+
expect(await readEntryContent(zipPath, "nested/b.txt")).toEqual(
|
|
621
|
+
Buffer.from("b"),
|
|
622
|
+
)
|
|
623
|
+
})
|
|
624
|
+
})
|
|
625
|
+
|
|
578
626
|
describe("normalizeExtractedTree", () => {
|
|
579
627
|
test("renames macOS %XX-escaped legacy names to the decoded listing name", async () => {
|
|
580
628
|
const root = tempRoot()
|
package/src/archive/extract.ts
CHANGED
|
@@ -66,6 +66,14 @@ export type ExtractArchiveOptions = {
|
|
|
66
66
|
/** Optional entry-count budget (enforced on the listing). */
|
|
67
67
|
readonly maxEntries?: number
|
|
68
68
|
readonly onProgress?: ZipExtractReporter
|
|
69
|
+
/**
|
|
70
|
+
* Optional format allow-list (e.g. `["zip"]` for plugin installs).
|
|
71
|
+
* An archive sniffed outside the list is rejected up front with
|
|
72
|
+
* `resource.archive_format_not_allowed` — project-internal channels
|
|
73
|
+
* that only ever produce one format stop admitting the others
|
|
74
|
+
* instead of opening every codec to untrusted input.
|
|
75
|
+
*/
|
|
76
|
+
readonly formats?: readonly ContainerFormat[]
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
/**
|
|
@@ -100,6 +108,13 @@ export async function extractArchiveInto(
|
|
|
100
108
|
{},
|
|
101
109
|
)
|
|
102
110
|
}
|
|
111
|
+
if (opts.formats !== undefined && !opts.formats.includes(format)) {
|
|
112
|
+
throw invalid(
|
|
113
|
+
"resource.archive_format_not_allowed",
|
|
114
|
+
`archive format ${format} is not allowed here (allowed: ${opts.formats.join(", ")})`,
|
|
115
|
+
{ allowed: opts.formats },
|
|
116
|
+
)
|
|
117
|
+
}
|
|
103
118
|
if (format === "zip" && resolveSevenZipPath() === undefined) {
|
|
104
119
|
return extractZipBuffer(buffer, destDir, opts)
|
|
105
120
|
}
|
package/src/archive/index.ts
CHANGED
|
@@ -102,7 +102,7 @@ export {
|
|
|
102
102
|
VIRTUAL_PATH_SEPARATOR,
|
|
103
103
|
} from "./nested-entry.ts"
|
|
104
104
|
export type { ZipStreamEntry } from "./pack.ts"
|
|
105
|
-
export { streamStoredZip } from "./pack.ts"
|
|
105
|
+
export { packZipDirectory, streamStoredZip } from "./pack.ts"
|
|
106
106
|
export type { ArchiveSource, ZipEntry } from "./zip-entries.ts"
|
|
107
107
|
export {
|
|
108
108
|
createFileArchiveSource,
|
package/src/archive/pack.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { createWriteStream, readdirSync } from "node:fs"
|
|
2
|
+
import { join, resolve, sep } from "node:path"
|
|
1
3
|
import type { Readable } from "node:stream"
|
|
4
|
+
import { pipeline } from "node:stream/promises"
|
|
2
5
|
import yazl from "yazl"
|
|
3
6
|
|
|
4
7
|
/**
|
|
@@ -38,3 +41,50 @@ export function streamStoredZip(entries: readonly ZipStreamEntry[]) {
|
|
|
38
41
|
zip.end()
|
|
39
42
|
return zip.outputStream
|
|
40
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pack the *contents* of `srcDir` (its entries at the archive root) into
|
|
47
|
+
* a deflated zip written to `zipPath`. Entry names use forward slashes,
|
|
48
|
+
* the file order is sorted by relative path, and the output is streamed
|
|
49
|
+
* to disk — deterministic on every platform, memory-bounded regardless
|
|
50
|
+
* of dist size. The canonical packer for project-internal zips (the
|
|
51
|
+
* plugin release artifact via the CLI); resource exports use
|
|
52
|
+
* {@link streamStoredZip} instead.
|
|
53
|
+
*/
|
|
54
|
+
export async function packZipDirectory(
|
|
55
|
+
srcDir: string,
|
|
56
|
+
zipPath: string,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const root = resolve(srcDir)
|
|
59
|
+
const files = listFilesSorted(root)
|
|
60
|
+
const zip = new yazl.ZipFile()
|
|
61
|
+
for (const abs of files) {
|
|
62
|
+
// yazl wants metadata names with forward slashes; the local-path
|
|
63
|
+
// basename order is deterministic thanks to listFilesSorted.
|
|
64
|
+
const rel = abs
|
|
65
|
+
.slice(root.length + 1)
|
|
66
|
+
.split(sep)
|
|
67
|
+
.join("/")
|
|
68
|
+
zip.addFile(abs, rel, { compress: true })
|
|
69
|
+
}
|
|
70
|
+
zip.end()
|
|
71
|
+
await pipeline(zip.outputStream, createWriteStream(resolve(zipPath)))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Every regular file under `root`, sorted by relative path (recursive). */
|
|
75
|
+
function listFilesSorted(root: string): string[] {
|
|
76
|
+
const out: string[] = []
|
|
77
|
+
const walk = (dir: string) => {
|
|
78
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
79
|
+
const abs = join(dir, entry.name)
|
|
80
|
+
if (entry.isDirectory()) {
|
|
81
|
+
walk(abs)
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (!entry.isFile()) continue
|
|
85
|
+
out.push(abs)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
walk(root)
|
|
89
|
+
return out.sort()
|
|
90
|
+
}
|
package/src/hoard/paths.test.ts
CHANGED
package/src/hoard/paths.ts
CHANGED
|
@@ -235,6 +235,14 @@ export type LocalPaths = {
|
|
|
235
235
|
* `local/` (never synced) so each host has its own seal key.
|
|
236
236
|
*/
|
|
237
237
|
sessionKey(): string
|
|
238
|
+
/**
|
|
239
|
+
* Path to the seed-removal marker file: `<root>/local/seed-removals.json`.
|
|
240
|
+
* Holds the plugin ids (UUIDs) whose bundled seed was deliberately
|
|
241
|
+
* uninstalled by this host, so boot-time seeding skips them until the
|
|
242
|
+
* user restores them. Host-only state — never synced, survives app
|
|
243
|
+
* updates, and stays out of the wipe-on-clear `cache/` tree.
|
|
244
|
+
*/
|
|
245
|
+
seedRemovals(): string
|
|
238
246
|
/**
|
|
239
247
|
* Root of the host-only temporary directory tree:
|
|
240
248
|
* `<localRoot>/.tmp`. Holds the global staging pool
|
|
@@ -398,6 +406,7 @@ export function createStoragePaths(
|
|
|
398
406
|
tmp: () => join(cacheRoot, "tmp"),
|
|
399
407
|
tmpFile: (name) => join(cacheRoot, "tmp", assertSafeSegment(name)),
|
|
400
408
|
sessionKey: () => join(localRoot, ".session-key"),
|
|
409
|
+
seedRemovals: () => join(localRoot, "seed-removals.json"),
|
|
401
410
|
uploadStagingRoot: () => uploadStagingRootPath,
|
|
402
411
|
stagingPoolRoot: () => join(uploadStagingRootPath, "staging"),
|
|
403
412
|
stagingPoolFile: (fileId, ext) =>
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Sandbox fixture: exercises the plugin asset API within detect — reads
|
|
2
|
-
// the vault stat,
|
|
3
|
-
// the manifest permission gate and the wired
|
|
2
|
+
// the vault stat, performs a consent-gated single download and a batch
|
|
3
|
+
// download. Used to verify the manifest permission gate and the wired
|
|
4
|
+
// plugin asset handler (single in → single out, array in → array out).
|
|
4
5
|
export default {
|
|
5
6
|
detect: async (api) => {
|
|
6
7
|
const stat = await api.statAsset("runtime/a.mjs")
|
|
@@ -8,6 +9,10 @@ export default {
|
|
|
8
9
|
url: "https://example.com/runtime/a.mjs",
|
|
9
10
|
dest: "runtime/a.mjs",
|
|
10
11
|
})
|
|
11
|
-
|
|
12
|
+
const batched = await api.download([
|
|
13
|
+
{ url: "https://example.com/runtime/b.mjs", dest: "runtime/b.mjs" },
|
|
14
|
+
{ url: "https://example.com/runtime/c.mjs", dest: "runtime/c.mjs" },
|
|
15
|
+
])
|
|
16
|
+
return { ok: true, stat, downloaded, batched }
|
|
12
17
|
},
|
|
13
18
|
}
|
package/src/sandbox/host.test.ts
CHANGED
|
@@ -548,12 +548,13 @@ describe("plugin sandbox", () => {
|
|
|
548
548
|
pluginAssets: {
|
|
549
549
|
download: async (pluginId, request) => {
|
|
550
550
|
seen.push([pluginId, request])
|
|
551
|
-
|
|
552
|
-
|
|
551
|
+
const requests = Array.isArray(request) ? request : [request]
|
|
552
|
+
return requests.map((req) => ({
|
|
553
|
+
path: req.dest,
|
|
553
554
|
sizeBytes: 3,
|
|
554
555
|
sha256: "a".repeat(64),
|
|
555
556
|
cached: false,
|
|
556
|
-
}
|
|
557
|
+
}))
|
|
557
558
|
},
|
|
558
559
|
statAsset: async (pluginId, path) => {
|
|
559
560
|
seen.push([pluginId, path])
|
|
@@ -574,12 +575,27 @@ describe("plugin sandbox", () => {
|
|
|
574
575
|
await expect(plugin.detect(createStubApi())).resolves.toEqual({
|
|
575
576
|
ok: true,
|
|
576
577
|
stat: undefined,
|
|
578
|
+
// Single in → single result out, batch in → array out.
|
|
577
579
|
downloaded: {
|
|
578
580
|
path: "runtime/a.mjs",
|
|
579
581
|
sizeBytes: 3,
|
|
580
582
|
sha256: "a".repeat(64),
|
|
581
583
|
cached: false,
|
|
582
584
|
},
|
|
585
|
+
batched: [
|
|
586
|
+
{
|
|
587
|
+
path: "runtime/b.mjs",
|
|
588
|
+
sizeBytes: 3,
|
|
589
|
+
sha256: "a".repeat(64),
|
|
590
|
+
cached: false,
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
path: "runtime/c.mjs",
|
|
594
|
+
sizeBytes: 3,
|
|
595
|
+
sha256: "a".repeat(64),
|
|
596
|
+
cached: false,
|
|
597
|
+
},
|
|
598
|
+
],
|
|
583
599
|
})
|
|
584
600
|
expect(seen[0]).toEqual(["asset-allowed", "runtime/a.mjs"])
|
|
585
601
|
expect(seen[1]).toEqual([
|
|
@@ -589,6 +605,13 @@ describe("plugin sandbox", () => {
|
|
|
589
605
|
dest: "runtime/a.mjs",
|
|
590
606
|
},
|
|
591
607
|
])
|
|
608
|
+
expect(seen[2]).toEqual([
|
|
609
|
+
"asset-allowed",
|
|
610
|
+
[
|
|
611
|
+
{ url: "https://example.com/runtime/b.mjs", dest: "runtime/b.mjs" },
|
|
612
|
+
{ url: "https://example.com/runtime/c.mjs", dest: "runtime/c.mjs" },
|
|
613
|
+
],
|
|
614
|
+
])
|
|
592
615
|
})
|
|
593
616
|
|
|
594
617
|
test("a log flood exceeds the per-hook budget and fails the hook", async () => {
|
package/src/sandbox/host.ts
CHANGED
|
@@ -33,10 +33,15 @@ import { createSandboxedPlugin } from "./sandboxed-plugin.ts"
|
|
|
33
33
|
* workbench) omit it and the methods answer `UNAVAILABLE`.
|
|
34
34
|
*/
|
|
35
35
|
export type PluginAssetHandler = {
|
|
36
|
+
/**
|
|
37
|
+
* Download one request or a batch of requests. Answers with the
|
|
38
|
+
* result array (batch shape) — this adapter unwraps the single
|
|
39
|
+
* result for a single request (see {@link dispatchApi}).
|
|
40
|
+
*/
|
|
36
41
|
readonly download: (
|
|
37
42
|
pluginId: string,
|
|
38
|
-
request: PluginDownloadRequest,
|
|
39
|
-
) => Promise<PluginDownloadResult>
|
|
43
|
+
request: PluginDownloadRequest | readonly PluginDownloadRequest[],
|
|
44
|
+
) => Promise<readonly PluginDownloadResult[]>
|
|
40
45
|
readonly statAsset: (
|
|
41
46
|
pluginId: string,
|
|
42
47
|
path: string,
|
|
@@ -804,13 +809,15 @@ export function createPluginSandbox(
|
|
|
804
809
|
return
|
|
805
810
|
}
|
|
806
811
|
// RPC boundary: the child-side proxy forwards args verbatim,
|
|
807
|
-
// so the contract order is guaranteed (request object
|
|
808
|
-
// `download`, string path for the rest).
|
|
812
|
+
// so the contract order is guaranteed (request object or
|
|
813
|
+
// request array for `download`, string path for the rest).
|
|
809
814
|
if (method === "download") {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
)
|
|
815
|
+
const request = args[0] as
|
|
816
|
+
| PluginDownloadRequest
|
|
817
|
+
| readonly PluginDownloadRequest[]
|
|
818
|
+
const results = await handler.download(state.id, request)
|
|
819
|
+
// Wire rule: array in → array out, single in → single out.
|
|
820
|
+
respond(true, Array.isArray(request) ? results : results[0])
|
|
814
821
|
} else if (method === "statAsset") {
|
|
815
822
|
respond(true, await handler.statAsset(state.id, args[0] as string))
|
|
816
823
|
} else if (method === "readAsset") {
|