@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.
@@ -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.0",
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.0"
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.0",
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()
@@ -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
  }
@@ -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,
@@ -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
+ }
@@ -100,6 +100,7 @@ describe("createStoragePaths", () => {
100
100
  paths.local.trash(),
101
101
  paths.local.logs(),
102
102
  paths.local.sessionKey(),
103
+ paths.local.seedRemovals(),
103
104
  paths.local.uploadStagingRoot(),
104
105
  paths.local.stagingPoolRoot(),
105
106
  ]
@@ -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
@@ -37,6 +37,7 @@ export {
37
37
  createNestedCdCache,
38
38
  listZipEntries,
39
39
  materializeFile,
40
+ packZipDirectory,
40
41
  } from "./archive/index.ts"
41
42
  export type {
42
43
  CapabilityGuard,
@@ -1,6 +1,7 @@
1
1
  // Sandbox fixture: exercises the plugin asset API within detect — reads
2
- // the vault stat, then performs a consent-gated download. Used to verify
3
- // the manifest permission gate and the wired plugin asset handler.
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
- return { ok: true, stat, downloaded }
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
  }
@@ -548,12 +548,13 @@ describe("plugin sandbox", () => {
548
548
  pluginAssets: {
549
549
  download: async (pluginId, request) => {
550
550
  seen.push([pluginId, request])
551
- return {
552
- path: request.dest,
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 () => {
@@ -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 for
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
- respond(
811
- true,
812
- await handler.download(state.id, args[0] as PluginDownloadRequest),
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") {