@hoardodile/host 0.1.7 → 0.1.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoardodile/host",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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.7"
70
+ "@hoardodile/sdk-types": "0.1.9"
71
71
  },
72
72
  "optionalDependencies": {
73
73
  "@derhuerst/ffprobe-static": "^5.3.0",
@@ -0,0 +1,156 @@
1
+ // @vitest-environment node
2
+
3
+ import { execFileSync } from "node:child_process"
4
+ import {
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs"
11
+ import { tmpdir } from "node:os"
12
+ import { dirname, join } from "node:path"
13
+ import { Readable } from "node:stream"
14
+ import { afterEach, describe, expect, it } from "vitest"
15
+ import { makeZip } from "./__testutils__/zip-fixtures.ts"
16
+ import { createPluginResourceAPI } from "./api.ts"
17
+ import { resolveSevenZipPath } from "./archive/7z.ts"
18
+ import type { ResourceContainer } from "./container.ts"
19
+
20
+ const sevenZipAvailable = resolveSevenZipPath() !== undefined
21
+
22
+ function memoryContainer(
23
+ files: Readonly<Record<string, Buffer>>,
24
+ ): ResourceContainer {
25
+ return {
26
+ listEntries: async () => Object.keys(files).sort(),
27
+ readEntry: async (rel) => {
28
+ const buf = files[rel]
29
+ if (buf === undefined) throw new Error(`no entry ${rel}`)
30
+ return buf
31
+ },
32
+ readEntrySlice: async (rel, start, end) => {
33
+ const buf = files[rel]
34
+ if (buf === undefined) throw new Error(`no entry ${rel}`)
35
+ return buf.subarray(start, Math.min(end, buf.length))
36
+ },
37
+ openEntryStream: async (rel) => {
38
+ const buf = files[rel]
39
+ if (buf === undefined) throw new Error(`no entry ${rel}`)
40
+ return { stream: Readable.from([buf]), size: buf.length }
41
+ },
42
+ resolveByteRange: async (rel) => {
43
+ const buf = files[rel]
44
+ return buf === undefined ? undefined : { size: buf.length }
45
+ },
46
+ }
47
+ }
48
+
49
+ function makeSevenZip(
50
+ dir: string,
51
+ files: Readonly<Record<string, string>>,
52
+ ): Buffer {
53
+ const payload = join(dir, "payload")
54
+ mkdirSync(payload, { recursive: true })
55
+ for (const [name, content] of Object.entries(files)) {
56
+ const filePath = join(payload, name)
57
+ mkdirSync(dirname(filePath), { recursive: true })
58
+ writeFileSync(filePath, content)
59
+ }
60
+ const archivePath = join(dir, "book.cb7")
61
+ execFileSync(resolveSevenZipPath()!, ["a", "-t7z", archivePath, "."], {
62
+ cwd: payload,
63
+ stdio: "ignore",
64
+ })
65
+ return readFileSync(archivePath)
66
+ }
67
+
68
+ /**
69
+ * The plugin-facing container contract under the unified addressing model:
70
+ * an entry is addressed as `outer!inner` and read straight from the
71
+ * central directory (zip) or from the extraction cache (non-zip) after
72
+ * `extractArchive`. Both formats answer the same plugin API.
73
+ */
74
+ describe("plugin container addressing (outer!inner)", () => {
75
+ let cacheDir: string
76
+
77
+ afterEach(() => {
78
+ rmSync(cacheDir, { recursive: true, force: true })
79
+ })
80
+
81
+ function apiOf(containerName: string, archiveBytes: Buffer) {
82
+ cacheDir = mkdtempSync(join(tmpdir(), "archive-api-"))
83
+ return createPluginResourceAPI({
84
+ view: memoryContainer({ [containerName]: archiveBytes }),
85
+ extractCacheDir: cacheDir,
86
+ cacheScope: "test",
87
+ })
88
+ }
89
+
90
+ it("reads and lists a zip entry from the central directory", async () => {
91
+ const zip = makeZip([
92
+ {
93
+ name: "Ch1/001.jpg",
94
+ data: Uint8Array.from([1, 2, 3, 4, 5]),
95
+ method: 8,
96
+ },
97
+ { name: "Ch1/002.jpg", data: Uint8Array.from([11, 12, 13]) },
98
+ ])
99
+ const api = apiOf("book.cbz", zip)
100
+
101
+ // The cheap listing never materializes.
102
+ expect(
103
+ (await api.listContainer("book.cbz")).entries.map((e) => e.path),
104
+ ).toEqual(["Ch1/001.jpg", "Ch1/002.jpg"])
105
+ // The virtual entry reads straight from the central directory.
106
+ expect([...(await api.readFile("book.cbz!Ch1/001.jpg"))]).toEqual([
107
+ 1, 2, 3, 4, 5,
108
+ ])
109
+ expect(await api.statFile("book.cbz!Ch1/002.jpg")).toEqual({
110
+ sizeBytes: 3,
111
+ })
112
+
113
+ // A missing inner entry surfaces as a stat miss / read error.
114
+ expect(await api.statFile("book.cbz!nope.jpg")).toBeUndefined()
115
+ await expect(api.readFile("book.cbz!nope.jpg")).rejects.toThrow()
116
+ })
117
+
118
+ it.skipIf(!sevenZipAvailable)(
119
+ "extracts and reads a non-zip entry from the extraction cache",
120
+ async () => {
121
+ const archive = makeSevenZip(
122
+ mkdtempSync(join(tmpdir(), "archive-api-")),
123
+ {
124
+ "Ch1/001.jpg": "first",
125
+ "Ch1/002.jpg": "second",
126
+ },
127
+ )
128
+ const api = apiOf("book.cb7", archive)
129
+
130
+ // The cheap listing never materializes.
131
+ expect(
132
+ (await api.listContainer("book.cb7")).entries.map((e) => e.path),
133
+ ).toEqual(["Ch1/001.jpg", "Ch1/002.jpg"])
134
+
135
+ // Before materialization a non-zip inner is not readable — and
136
+ // this absent read must NOT poison the view for a later
137
+ // extract+read (the manifest memo only keeps positive results).
138
+ await expect(api.readFile("book.cb7!Ch1/001.jpg")).rejects.toThrow()
139
+
140
+ const extraction = await api.extractArchive("book.cb7")
141
+ expect(extraction.entries.map((e) => e.path)).toEqual([
142
+ "Ch1/001.jpg",
143
+ "Ch1/002.jpg",
144
+ ])
145
+
146
+ // After materialization the SAME outer!inner form reads from disk,
147
+ // even though the same view instance saw it absent a moment ago.
148
+ expect(
149
+ Buffer.from(await api.readFile("book.cb7!Ch1/001.jpg")).toString(),
150
+ ).toBe("first")
151
+ expect(await api.statFile("book.cb7!Ch1/002.jpg")).toEqual({
152
+ sizeBytes: 6,
153
+ })
154
+ },
155
+ )
156
+ })
@@ -53,25 +53,36 @@ export function createNestedAwareContainer(
53
53
  { cdCache: nestedCdCache, scope },
54
54
  )
55
55
 
56
- // Manifest reads are memoized per (cacheDir, outer): archives are
57
- // immutable per version, so a parsed manifest never needs invalidation.
56
+ // Manifest reads are memoized per (cacheDir, outer) to avoid re-parsing
57
+ // the JSON on every virtual read. A **present** manifest is immutable
58
+ // per version, so a parsed result never needs invalidation. An
59
+ // **absent** manifest, however, is only "not materialized yet": a
60
+ // plugin may probe a non-zip `outer!inner` before calling
61
+ // `extractArchive`, then materialize it. Pinning the "absent" state
62
+ // would make the same view stale forever, so the cache only keeps a
63
+ // positive result and re-checks after an absent read.
58
64
  const manifestMemo = new Map<
59
65
  string,
60
66
  Promise<readonly ExtractedEntry[] | undefined>
61
67
  >()
62
68
 
63
- function readManifest(
69
+ async function readManifest(
64
70
  outer: string,
65
71
  ): Promise<readonly ExtractedEntry[] | undefined> {
66
72
  const key = `${extractCacheDir}:${outer}`
67
73
  const pending = manifestMemo.get(key)
68
74
  if (pending !== undefined) return pending
75
+ // Share the in-flight read so concurrent callers do not re-read the
76
+ // same archive's manifest, then drop the key when the answer is
77
+ // "absent" so a later materialization is picked up.
69
78
  const work = readExistingManifest(
70
79
  join(extractCacheDir!, outer, "index.json"),
71
80
  outer,
72
81
  ).catch(() => undefined)
73
82
  manifestMemo.set(key, work)
74
- return work
83
+ const entries = await work
84
+ if (entries === undefined) manifestMemo.delete(key)
85
+ return entries
75
86
  }
76
87
 
77
88
  type MaterializedEntry = {