@hoardodile/host 0.1.1 → 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 +1 -0
- package/dist/index.js +49 -21
- package/dist/index.js.map +1 -1
- package/dist/pack-6IscrwYN.d.ts +35 -0
- package/package.json +2 -2
- 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
|
@@ -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,7 +67,7 @@
|
|
|
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",
|
|
@@ -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) =>
|