@hoardodile/host 0.0.0
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 +18 -0
- package/README.md +73 -0
- package/dist/chunks/worker-entry.mjs +422 -0
- package/dist/container-fKxefO7p.d.ts +58 -0
- package/dist/contract/index.d.ts +49 -0
- package/dist/contract/index.js +187 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/hoard/index.d.ts +754 -0
- package/dist/hoard/index.js +1939 -0
- package/dist/hoard/index.js.map +1 -0
- package/dist/image-Dx2Be3Bj.d.ts +168 -0
- package/dist/index.d.ts +788 -0
- package/dist/index.js +3311 -0
- package/dist/index.js.map +1 -0
- package/dist/media/index.d.ts +96 -0
- package/dist/media/index.js +152 -0
- package/dist/media/index.js.map +1 -0
- package/dist/probe/index.d.ts +113 -0
- package/dist/probe/index.js +454 -0
- package/dist/probe/index.js.map +1 -0
- package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
- package/dist/render/index.d.ts +125 -0
- package/dist/render/index.js +430 -0
- package/dist/render/index.js.map +1 -0
- package/package.json +103 -0
- package/src/__testutils__/zip-fixtures.ts +139 -0
- package/src/activation.ts +116 -0
- package/src/api-types.ts +60 -0
- package/src/api.test.ts +564 -0
- package/src/api.ts +501 -0
- package/src/archive/7z.test.ts +222 -0
- package/src/archive/7z.ts +261 -0
- package/src/archive/archive-ops.test.ts +654 -0
- package/src/archive/extract-archive.test.ts +237 -0
- package/src/archive/extract-archive.ts +534 -0
- package/src/archive/extract.ts +693 -0
- package/src/archive/format.ts +49 -0
- package/src/archive/index.ts +116 -0
- package/src/archive/listing.ts +121 -0
- package/src/archive/materialize.ts +88 -0
- package/src/archive/name-decode.test.ts +34 -0
- package/src/archive/name-decode.ts +287 -0
- package/src/archive/nested-cd-cache.ts +55 -0
- package/src/archive/nested-entry.test.ts +194 -0
- package/src/archive/nested-entry.ts +254 -0
- package/src/archive/pack.ts +40 -0
- package/src/archive/zip-entries.test.ts +56 -0
- package/src/archive/zip-entries.ts +336 -0
- package/src/capability-guard.ts +73 -0
- package/src/container.ts +62 -0
- package/src/contract/container-fixture.ts +74 -0
- package/src/contract/index.ts +10 -0
- package/src/contract/suite.test.ts +101 -0
- package/src/contract/suite.ts +205 -0
- package/src/define-plugin.test.ts +53 -0
- package/src/define-plugin.ts +12 -0
- package/src/dev-runner.test.ts +98 -0
- package/src/dev-runner.ts +73 -0
- package/src/directory-api.test.ts +229 -0
- package/src/directory-api.ts +42 -0
- package/src/directory-container.ts +141 -0
- package/src/discovery.ts +295 -0
- package/src/errors.ts +74 -0
- package/src/fixtures.ts +8 -0
- package/src/hash.test.ts +301 -0
- package/src/hash.ts +167 -0
- package/src/hoard/dir-size.ts +58 -0
- package/src/hoard/index.ts +84 -0
- package/src/hoard/migrate-script.test.ts +539 -0
- package/src/hoard/order-manifest.test.ts +115 -0
- package/src/hoard/order-manifest.ts +131 -0
- package/src/hoard/paths.test.ts +226 -0
- package/src/hoard/paths.ts +569 -0
- package/src/hoard/plugin-vault.test.ts +160 -0
- package/src/hoard/plugin-vault.ts +233 -0
- package/src/hoard/sanitize.test.ts +149 -0
- package/src/hoard/sanitize.ts +230 -0
- package/src/hoard/staging-dir.ts +133 -0
- package/src/hoard/version.test.ts +285 -0
- package/src/hoard/version.ts +216 -0
- package/src/hoard/versioned-folder-ops.ts +152 -0
- package/src/hoard/write-versioned.test.ts +67 -0
- package/src/hoard/write-versioned.ts +39 -0
- package/src/hooks.test.ts +528 -0
- package/src/hooks.ts +428 -0
- package/src/index.ts +135 -0
- package/src/loader.test.ts +442 -0
- package/src/loader.ts +213 -0
- package/src/media/index.ts +16 -0
- package/src/media/render-cache.ts +27 -0
- package/src/media/seekable.ts +35 -0
- package/src/media/thumb-input.test.ts +109 -0
- package/src/media/thumb-input.ts +194 -0
- package/src/nested-view.test.ts +223 -0
- package/src/nested-view.ts +239 -0
- package/src/probe/audio.test.ts +135 -0
- package/src/probe/audio.ts +140 -0
- package/src/probe/av.ts +84 -0
- package/src/probe/ffprobe.ts +197 -0
- package/src/probe/image.test.ts +251 -0
- package/src/probe/image.ts +336 -0
- package/src/probe/index.ts +54 -0
- package/src/probe/sniff.test.ts +72 -0
- package/src/probe/sniff.ts +67 -0
- package/src/probe/video.test.ts +41 -0
- package/src/probe/video.ts +116 -0
- package/src/probe-cache.test.ts +89 -0
- package/src/probe-cache.ts +65 -0
- package/src/render/ffmpeg.ts +78 -0
- package/src/render/index.ts +34 -0
- package/src/render/pipeline.test.ts +187 -0
- package/src/render/pipeline.ts +479 -0
- package/src/sandbox/call-timers.ts +68 -0
- package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
- package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
- package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
- package/src/sandbox/fixtures/container-plugin.mjs +8 -0
- package/src/sandbox/fixtures/context-plugin.mjs +19 -0
- package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
- package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
- package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
- package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
- package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
- package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
- package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
- package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
- package/src/sandbox/fixtures/range-plugin.mjs +9 -0
- package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
- package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
- package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
- package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
- package/src/sandbox/host.test.ts +619 -0
- package/src/sandbox/host.ts +933 -0
- package/src/sandbox/host.unit.test.ts +330 -0
- package/src/sandbox/protocol.test.ts +85 -0
- package/src/sandbox/protocol.ts +147 -0
- package/src/sandbox/sandboxed-plugin.ts +30 -0
- package/src/sandbox/worker-entry.mjs +422 -0
- package/src/seed.test.ts +102 -0
- package/src/seed.ts +160 -0
- package/src/settings-store.ts +26 -0
- package/src/types.ts +21 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { mkdtempSync } from "node:fs"
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join, resolve } from "node:path"
|
|
5
|
+
import { afterEach, describe, expect, test } from "vitest"
|
|
6
|
+
import {
|
|
7
|
+
commitVaultFile,
|
|
8
|
+
discardVaultTempFile,
|
|
9
|
+
PluginVaultPathError,
|
|
10
|
+
parsePluginVaultDest,
|
|
11
|
+
vaultFileSha256,
|
|
12
|
+
vaultReadFile,
|
|
13
|
+
vaultRemoveFile,
|
|
14
|
+
vaultStatFile,
|
|
15
|
+
vaultTempFile,
|
|
16
|
+
vaultTotalSize,
|
|
17
|
+
} from "./plugin-vault.ts"
|
|
18
|
+
|
|
19
|
+
const roots: string[] = []
|
|
20
|
+
|
|
21
|
+
function vaultDir(): string {
|
|
22
|
+
const root = mkdtempSync(join(tmpdir(), "hoardodile-vault-"))
|
|
23
|
+
roots.push(root)
|
|
24
|
+
return join(root, "vault")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await Promise.all(
|
|
29
|
+
roots.splice(0).map((r) => rm(r, { recursive: true, force: true })),
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe("parsePluginVaultDest (isolation)", () => {
|
|
34
|
+
// Portability: the fixture dir must be a real absolute path on every
|
|
35
|
+
// OS — a hardcoded Windows drive path broke the POSIX runners.
|
|
36
|
+
const dir = join(tmpdir(), "vaults", "p")
|
|
37
|
+
|
|
38
|
+
test("accepts nested relative destinations", () => {
|
|
39
|
+
expect(parsePluginVaultDest(dir, "runtime/live2d.min.js")).toEqual({
|
|
40
|
+
rel: "runtime/live2d.min.js",
|
|
41
|
+
// assertInside returns `resolve(candidate)`.
|
|
42
|
+
abs: resolve(dir, "runtime", "live2d.min.js"),
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("rejects traversal", () => {
|
|
47
|
+
for (const dest of ["../main.js", "a/../../main.js", "..", "a/.."]) {
|
|
48
|
+
expect(() => parsePluginVaultDest(dir, dest)).toThrow(
|
|
49
|
+
PluginVaultPathError,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("rejects absolute destinations and drive letters", () => {
|
|
55
|
+
// path-guard-exempt: Windows-style destinations are the behavior under test.
|
|
56
|
+
for (const dest of ["/etc/passwd", "C:\\windows\\x", "//host/share"]) {
|
|
57
|
+
expect(() => parsePluginVaultDest(dir, dest)).toThrow()
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("rejects empty segments, reserved names and trailing dots", () => {
|
|
62
|
+
for (const dest of [
|
|
63
|
+
"",
|
|
64
|
+
"a//b",
|
|
65
|
+
"a/",
|
|
66
|
+
"CON",
|
|
67
|
+
"aux.txt",
|
|
68
|
+
"a/.",
|
|
69
|
+
"a..",
|
|
70
|
+
"x.",
|
|
71
|
+
]) {
|
|
72
|
+
expect(() => parsePluginVaultDest(dir, dest)).toThrow()
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe("vault primitives", () => {
|
|
78
|
+
test("stat/read/write/commit/remove round-trip with atomic commits", async () => {
|
|
79
|
+
const dir = vaultDir()
|
|
80
|
+
await mkdir(dir, { recursive: true })
|
|
81
|
+
|
|
82
|
+
expect(await vaultStatFile(dir, "runtime/a.mjs")).toBeUndefined()
|
|
83
|
+
expect(await vaultRemoveFile(dir, "runtime/a.mjs")).toBe(false)
|
|
84
|
+
|
|
85
|
+
const temp = vaultTempFile(dir)
|
|
86
|
+
await writeFile(temp, "const a = 1\n")
|
|
87
|
+
const commit = await commitVaultFile({
|
|
88
|
+
vaultDir: dir,
|
|
89
|
+
rel: "runtime/a.mjs",
|
|
90
|
+
tempPath: temp,
|
|
91
|
+
maxFileBytes: 1024,
|
|
92
|
+
maxTotalBytes: 4096,
|
|
93
|
+
})
|
|
94
|
+
expect(commit.sizeBytes).toBe(12)
|
|
95
|
+
expect(commit.sha256).toMatch(/^[0-9a-f]{64}$/)
|
|
96
|
+
|
|
97
|
+
expect(await vaultStatFile(dir, "runtime/a.mjs")).toEqual({
|
|
98
|
+
sizeBytes: 12,
|
|
99
|
+
})
|
|
100
|
+
expect(
|
|
101
|
+
new TextDecoder().decode(
|
|
102
|
+
await vaultReadFile(dir, "runtime/a.mjs", 1_000_000),
|
|
103
|
+
),
|
|
104
|
+
).toBe("const a = 1\n")
|
|
105
|
+
expect(await vaultFileSha256(dir, "runtime/a.mjs")).toBe(commit.sha256)
|
|
106
|
+
expect(await vaultTotalSize(dir)).toBe(12)
|
|
107
|
+
expect(await vaultRemoveFile(dir, "runtime/a.mjs")).toBe(true)
|
|
108
|
+
expect(await vaultTotalSize(dir)).toBe(0)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test("commit rejects oversized files and over-quota totals (no partial file)", async () => {
|
|
112
|
+
const dir = vaultDir()
|
|
113
|
+
await mkdir(dir, { recursive: true })
|
|
114
|
+
await writeFile(join(dir, "existing.bin"), "x".repeat(100))
|
|
115
|
+
|
|
116
|
+
const tooBig = vaultTempFile(dir)
|
|
117
|
+
await writeFile(tooBig, "x".repeat(200 + 1))
|
|
118
|
+
await expect(
|
|
119
|
+
commitVaultFile({
|
|
120
|
+
vaultDir: dir,
|
|
121
|
+
rel: "big.bin",
|
|
122
|
+
tempPath: tooBig,
|
|
123
|
+
maxFileBytes: 100,
|
|
124
|
+
maxTotalBytes: 200,
|
|
125
|
+
}),
|
|
126
|
+
).rejects.toThrow(/download cap/)
|
|
127
|
+
await expect(vaultStatFile(dir, "big.bin")).resolves.toBeUndefined()
|
|
128
|
+
|
|
129
|
+
// Replacing an existing file counts the old bytes only once.
|
|
130
|
+
const fresh = vaultTempFile(dir)
|
|
131
|
+
await writeFile(fresh, "y".repeat(150))
|
|
132
|
+
await expect(
|
|
133
|
+
commitVaultFile({
|
|
134
|
+
vaultDir: dir,
|
|
135
|
+
rel: "existing.bin",
|
|
136
|
+
tempPath: fresh,
|
|
137
|
+
maxFileBytes: 1000,
|
|
138
|
+
maxTotalBytes: 200,
|
|
139
|
+
}),
|
|
140
|
+
).resolves.toMatchObject({ sizeBytes: 150 })
|
|
141
|
+
expect(await vaultTotalSize(dir)).toBe(150)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test("delete refuses directories and outside-the-vault paths", async () => {
|
|
145
|
+
const dir = vaultDir()
|
|
146
|
+
await mkdir(join(dir, "sub"), { recursive: true })
|
|
147
|
+
await expect(vaultRemoveFile(dir, "sub")).rejects.toThrow(/not a file/)
|
|
148
|
+
await expect(vaultRemoveFile(dir, "../outside")).rejects.toThrow()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("discard removes temp files without throwing", async () => {
|
|
152
|
+
const dir = vaultDir()
|
|
153
|
+
await mkdir(dir, { recursive: true })
|
|
154
|
+
const temp = vaultTempFile(dir)
|
|
155
|
+
await writeFile(temp, "partial")
|
|
156
|
+
await discardVaultTempFile(temp)
|
|
157
|
+
await expect(readFile(temp)).rejects.toThrow()
|
|
158
|
+
await expect(discardVaultTempFile(temp)).resolves.toBeUndefined()
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin asset vault storage primitives: resolving vault-relative
|
|
3
|
+
* destinations (the isolation boundary — a `dest` can never escape the
|
|
4
|
+
* plugin's own vault), reading/statting vault files, removing them, and
|
|
5
|
+
* committing downloaded files atomically.
|
|
6
|
+
*
|
|
7
|
+
* The vault root is `<version>/plugins/<id>/vault` (see
|
|
8
|
+
* {@link VersionPaths.pluginVaultDir}); the primitives operate on an
|
|
9
|
+
* explicit vault directory so the server can pass the `latest` version
|
|
10
|
+
* for writes (inside `writeVersioned`) or the active version for reads.
|
|
11
|
+
*
|
|
12
|
+
* Cost note (deliberate): the vault lives inside the versioned tree, so
|
|
13
|
+
* each archive version snapshots a full copy of it — durability over
|
|
14
|
+
* deduplication, same as `resources/<id>/data/`.
|
|
15
|
+
*
|
|
16
|
+
* Misuse answers {@link PluginAssetError} with `POLICY` so the plugin
|
|
17
|
+
* sees a machine-readable rejection before any network happens.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
20
|
+
import { createReadStream } from "node:fs"
|
|
21
|
+
import { mkdir, readFile, rename, rm, stat } from "node:fs/promises"
|
|
22
|
+
import { join, resolve } from "node:path"
|
|
23
|
+
import { pluginAssetError } from "@hoardodile/sdk-types"
|
|
24
|
+
import { sumDirSizes } from "./dir-size.ts"
|
|
25
|
+
import { assertInside, assertSafeSegment } from "./paths.ts"
|
|
26
|
+
|
|
27
|
+
/** Thrown when a vault path violates the isolation rules (POLICY). */
|
|
28
|
+
export class PluginVaultPathError extends Error {
|
|
29
|
+
constructor(message: string) {
|
|
30
|
+
super(message)
|
|
31
|
+
this.name = "PluginVaultPathError"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse a vault-relative destination and resolve it against the vault
|
|
37
|
+
* root. Every segment goes through {@link assertSafeSegment} (no empty
|
|
38
|
+
* segments, `.`/`..`, separators inside a segment, drive letters,
|
|
39
|
+
* control chars, Windows-reserved names, trailing dot/space) and the
|
|
40
|
+
* final path is re-checked with {@link assertInside} — doubly so, the
|
|
41
|
+
* whole point is that a downloaded file can only ever land inside the
|
|
42
|
+
* plugin's own vault.
|
|
43
|
+
*/
|
|
44
|
+
export function parsePluginVaultDest(
|
|
45
|
+
vaultDir: string,
|
|
46
|
+
dest: string,
|
|
47
|
+
): { readonly rel: string; readonly abs: string } {
|
|
48
|
+
if (dest.length === 0) {
|
|
49
|
+
throw new PluginVaultPathError("plugin vault destination must not be empty")
|
|
50
|
+
}
|
|
51
|
+
const segments = dest.split("/")
|
|
52
|
+
let abs = vaultDir
|
|
53
|
+
for (const segment of segments) {
|
|
54
|
+
if (segment.length === 0) {
|
|
55
|
+
throw new PluginVaultPathError(
|
|
56
|
+
`plugin vault destination must not contain empty segments: ${JSON.stringify(dest)}`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
abs = join(abs, assertSafeSegment(segment))
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// assertSafeSegment answers plain Errors — the boundary keeps
|
|
63
|
+
// one vocabulary so callers can map them to POLICY uniformly.
|
|
64
|
+
throw new PluginVaultPathError(
|
|
65
|
+
err instanceof Error ? err.message : String(err),
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const resolved = assertInside(vaultDir, abs)
|
|
71
|
+
return { rel: segments.join("/"), abs: resolved }
|
|
72
|
+
} catch (err) {
|
|
73
|
+
throw new PluginVaultPathError(
|
|
74
|
+
err instanceof Error ? err.message : String(err),
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Byte size of a vault file (or `undefined` when absent or not a
|
|
81
|
+
* regular file). The cheap presence check `download` builds on.
|
|
82
|
+
*/
|
|
83
|
+
export async function vaultStatFile(
|
|
84
|
+
vaultDir: string,
|
|
85
|
+
rel: string,
|
|
86
|
+
): Promise<{ readonly sizeBytes: number } | undefined> {
|
|
87
|
+
const { abs } = parsePluginVaultDest(vaultDir, rel)
|
|
88
|
+
const info = await stat(abs).catch(() => undefined)
|
|
89
|
+
if (info === undefined || !info.isFile()) return undefined
|
|
90
|
+
return { sizeBytes: info.size }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Stream a vault file's bytes (bounded by `maxBytes`). */
|
|
94
|
+
export async function vaultReadFile(
|
|
95
|
+
vaultDir: string,
|
|
96
|
+
rel: string,
|
|
97
|
+
maxBytes: number,
|
|
98
|
+
): Promise<Uint8Array> {
|
|
99
|
+
const { abs } = parsePluginVaultDest(vaultDir, rel)
|
|
100
|
+
const info = await stat(abs).catch(() => undefined)
|
|
101
|
+
if (info === undefined || !info.isFile()) {
|
|
102
|
+
throw pluginAssetError("POLICY", `plugin vault file not found: ${rel}`)
|
|
103
|
+
}
|
|
104
|
+
if (info.size > maxBytes) {
|
|
105
|
+
throw pluginAssetError(
|
|
106
|
+
"POLICY",
|
|
107
|
+
`plugin vault file "${rel}" exceeds the ${maxBytes}-byte read cap`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
const bytes = await readFile(abs)
|
|
111
|
+
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Remove a vault file. Idempotent (absent → false); directories and
|
|
116
|
+
* anything outside the vault are rejected.
|
|
117
|
+
*/
|
|
118
|
+
export async function vaultRemoveFile(
|
|
119
|
+
vaultDir: string,
|
|
120
|
+
rel: string,
|
|
121
|
+
): Promise<boolean> {
|
|
122
|
+
const { abs } = parsePluginVaultDest(vaultDir, rel)
|
|
123
|
+
const info = await stat(abs).catch(() => undefined)
|
|
124
|
+
if (info === undefined) return false
|
|
125
|
+
if (!info.isFile()) {
|
|
126
|
+
throw pluginAssetError(
|
|
127
|
+
"POLICY",
|
|
128
|
+
`plugin vault destination is not a file: ${rel}`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
await rm(abs)
|
|
132
|
+
return true
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Path of the caller's download temp file inside the vault (host-side
|
|
137
|
+
* staging — never a user-visible name). The downloader streams into it;
|
|
138
|
+
* {@link commitVaultFile} renames it into place.
|
|
139
|
+
*/
|
|
140
|
+
export function vaultTempFile(vaultDir: string): string {
|
|
141
|
+
return join(vaultDir, `.${randomUUID()}.tmp`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Delete a temp file (download failure path). */
|
|
145
|
+
export async function discardVaultTempFile(tempPath: string): Promise<void> {
|
|
146
|
+
await rm(tempPath, { force: true }).catch(() => {})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Commit a downloaded temp file into the vault: mkdir the nested
|
|
151
|
+
* destination, verify the per-plugin total-size budget (the destination's
|
|
152
|
+
* own previous bytes count against the quota only once — re-downloading
|
|
153
|
+
* replaces, it does not add), then rename the temp file into place
|
|
154
|
+
* (atomic — the final path never holds a partial download).
|
|
155
|
+
*/
|
|
156
|
+
export async function commitVaultFile(opts: {
|
|
157
|
+
readonly vaultDir: string
|
|
158
|
+
readonly rel: string
|
|
159
|
+
readonly tempPath: string
|
|
160
|
+
readonly maxFileBytes: number
|
|
161
|
+
readonly maxTotalBytes: number
|
|
162
|
+
}): Promise<{ readonly sizeBytes: number; readonly sha256: string }> {
|
|
163
|
+
const { abs } = parsePluginVaultDest(opts.vaultDir, opts.rel)
|
|
164
|
+
const existing = await stat(abs).catch(() => undefined)
|
|
165
|
+
if (existing !== undefined && !existing.isFile()) {
|
|
166
|
+
throw pluginAssetError(
|
|
167
|
+
"POLICY",
|
|
168
|
+
`plugin vault destination is not a file: ${opts.rel}`,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
const tempInfo = await stat(opts.tempPath).catch(() => undefined)
|
|
172
|
+
if (tempInfo === undefined || !tempInfo.isFile()) {
|
|
173
|
+
throw pluginAssetError(
|
|
174
|
+
"POLICY",
|
|
175
|
+
`plugin vault download lost its staging file: ${opts.rel}`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
if (tempInfo.size > opts.maxFileBytes) {
|
|
179
|
+
throw pluginAssetError(
|
|
180
|
+
"POLICY",
|
|
181
|
+
`plugin vault file "${opts.rel}" exceeds the ${opts.maxFileBytes}-byte download cap`,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
const currentTotal = await vaultTotalSize(opts.vaultDir)
|
|
185
|
+
const replacedBytes = existing?.isFile() ? existing.size : 0
|
|
186
|
+
if (currentTotal - replacedBytes + tempInfo.size > opts.maxTotalBytes) {
|
|
187
|
+
throw pluginAssetError(
|
|
188
|
+
"POLICY",
|
|
189
|
+
`plugin vault "${opts.rel}" would exceed the ${opts.maxTotalBytes}-byte plugin quota (current ${currentTotal})`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
await mkdir(resolve(abs, ".."), { recursive: true })
|
|
193
|
+
await rename(opts.tempPath, abs)
|
|
194
|
+
return { sizeBytes: tempInfo.size, sha256: await hashFileAbs(abs) }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Total bytes of the vault's regular files (shared directory-size walk;
|
|
199
|
+
* host temp staging files — leading `.` — are excluded as transient).
|
|
200
|
+
*/
|
|
201
|
+
export async function vaultTotalSize(vaultDir: string): Promise<number> {
|
|
202
|
+
return sumDirSizes(vaultDir, { excludeDotPrefix: true })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** sha256 of a vault file's bytes (streamed; callers verify pins against it). */
|
|
206
|
+
export async function vaultFileSha256(
|
|
207
|
+
vaultDir: string,
|
|
208
|
+
rel: string,
|
|
209
|
+
): Promise<string> {
|
|
210
|
+
const { abs } = parsePluginVaultDest(vaultDir, rel)
|
|
211
|
+
const info = await stat(abs).catch(() => undefined)
|
|
212
|
+
if (info === undefined || !info.isFile()) {
|
|
213
|
+
throw pluginAssetError("POLICY", `plugin vault file not found: ${rel}`)
|
|
214
|
+
}
|
|
215
|
+
return hashFileAbs(abs)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function hashFileAbs(abs: string): Promise<string> {
|
|
219
|
+
const hash = createHash("sha256")
|
|
220
|
+
await new Promise<void>((resolveDone, reject) => {
|
|
221
|
+
const stream = createReadStream(abs)
|
|
222
|
+
stream.on("data", (chunk: string | Buffer) => hash.update(chunk))
|
|
223
|
+
stream.on("end", () => resolveDone())
|
|
224
|
+
stream.on("error", reject)
|
|
225
|
+
})
|
|
226
|
+
return hash.digest("hex")
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Result of {@link commitVaultFile}: the stored file's identity. */
|
|
230
|
+
export type VaultCommitResult = {
|
|
231
|
+
readonly sizeBytes: number
|
|
232
|
+
readonly sha256: string
|
|
233
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
createOccupiedNames,
|
|
4
|
+
occupyEntryName,
|
|
5
|
+
sanitizeEntryName,
|
|
6
|
+
uniqueEntryName,
|
|
7
|
+
} from "./sanitize.ts"
|
|
8
|
+
|
|
9
|
+
describe("sanitizeEntryName", () => {
|
|
10
|
+
test("replaces Windows-forbidden characters with underscores", () => {
|
|
11
|
+
expect(sanitizeEntryName('a<b>c:d"e|f?g*h')).toBe("a_b_c_d_e_f_g_h")
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test("treats backslashes as separators", () => {
|
|
15
|
+
expect(sanitizeEntryName("dir\\sub\\file.txt")).toBe("dir/sub/file.txt")
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test("strips control characters", () => {
|
|
19
|
+
expect(sanitizeEntryName("a\x01b\x1f.txt")).toBe("ab.txt")
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("rejects null bytes", () => {
|
|
23
|
+
expect(sanitizeEntryName("a\u0000b")).toBeUndefined()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("drops . and .. segments instead of escaping", () => {
|
|
27
|
+
expect(sanitizeEntryName("../a/./b.txt")).toBe("a/b.txt")
|
|
28
|
+
expect(sanitizeEntryName("../../etc/passwd")).toBe("etc/passwd")
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("strips leading dots (metadata namespace) and trailing dots/spaces", () => {
|
|
32
|
+
expect(sanitizeEntryName(".hidden.txt")).toBe("hidden.txt")
|
|
33
|
+
expect(sanitizeEntryName("...dots.txt")).toBe("dots.txt")
|
|
34
|
+
expect(sanitizeEntryName("name.")).toBe("name")
|
|
35
|
+
expect(sanitizeEntryName("name ")).toBe("name")
|
|
36
|
+
expect(sanitizeEntryName("dir./file. ")).toBe("dir/file")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("prefixes Windows reserved base names incl. extension variants", () => {
|
|
40
|
+
expect(sanitizeEntryName("CON")).toBe("_CON")
|
|
41
|
+
expect(sanitizeEntryName("con.txt")).toBe("_con.txt")
|
|
42
|
+
expect(sanitizeEntryName("COM1")).toBe("_COM1")
|
|
43
|
+
expect(sanitizeEntryName("LPT9.x")).toBe("_LPT9.x")
|
|
44
|
+
expect(sanitizeEntryName("NUL")).toBe("_NUL")
|
|
45
|
+
expect(sanitizeEntryName("CONIN$")).toBe("_CONIN$")
|
|
46
|
+
expect(sanitizeEntryName("CONOUT$.txt")).toBe("_CONOUT$.txt")
|
|
47
|
+
expect(sanitizeEntryName("COM0")).toBe("_COM0")
|
|
48
|
+
expect(sanitizeEntryName("COM\u00b9")).toBe("_COM\u00b9")
|
|
49
|
+
expect(sanitizeEntryName("LPT\u00b3.bin")).toBe("_LPT\u00b3.bin")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test("preserves subdirectory structure", () => {
|
|
53
|
+
expect(sanitizeEntryName("Chapter 1/001.jpg")).toBe("Chapter 1/001.jpg")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("normalizes to NFC", () => {
|
|
57
|
+
const decomposed = "cafe\u0301.txt"
|
|
58
|
+
expect(sanitizeEntryName(decomposed)).toBe("caf\u00e9.txt")
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("caps segments at 240 UTF-8 bytes", () => {
|
|
62
|
+
const long = `${"中".repeat(200)}.txt`
|
|
63
|
+
const result = sanitizeEntryName(long)
|
|
64
|
+
expect(result).toBeDefined()
|
|
65
|
+
const name = result!.split("/").at(-1)!
|
|
66
|
+
expect(Buffer.byteLength(name, "utf8")).toBeLessThanOrEqual(243)
|
|
67
|
+
expect(name.includes("\uFFFD")).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("caps emoji segments by bytes, not code units", () => {
|
|
71
|
+
const long = "😀".repeat(200)
|
|
72
|
+
const result = sanitizeEntryName(long)
|
|
73
|
+
expect(result).toBeDefined()
|
|
74
|
+
expect(Buffer.byteLength(result!, "utf8")).toBeLessThanOrEqual(240)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test("returns undefined for unusable names", () => {
|
|
78
|
+
expect(sanitizeEntryName("")).toBeUndefined()
|
|
79
|
+
expect(sanitizeEntryName("....")).toBeUndefined()
|
|
80
|
+
expect(sanitizeEntryName("\u0000")).toBeUndefined()
|
|
81
|
+
expect(sanitizeEntryName("///")).toBeUndefined()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("rejects relative paths over the total length cap", () => {
|
|
85
|
+
const segments = Array.from({ length: 300 }, () => "abc")
|
|
86
|
+
expect(sanitizeEntryName(segments.join("/"))).toBeUndefined()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe("uniqueEntryName", () => {
|
|
91
|
+
test("appends -N suffix on exact collisions", () => {
|
|
92
|
+
const occupied = createOccupiedNames({ files: ["a.png"] })
|
|
93
|
+
expect(uniqueEntryName(occupied, "a.png")).toBe("a-1.png")
|
|
94
|
+
occupyEntryName(occupied, "a-1.png")
|
|
95
|
+
expect(uniqueEntryName(occupied, "a.png")).toBe("a-2.png")
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test("collides case-insensitively", () => {
|
|
99
|
+
expect(
|
|
100
|
+
uniqueEntryName(createOccupiedNames({ files: ["A.PNG"] }), "a.png"),
|
|
101
|
+
).toBe("a-1.png")
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test("keeps the extension in the suffix", () => {
|
|
105
|
+
expect(
|
|
106
|
+
uniqueEntryName(createOccupiedNames({ files: ["page.jpg"] }), "page.jpg"),
|
|
107
|
+
).toBe("page-1.jpg")
|
|
108
|
+
expect(
|
|
109
|
+
uniqueEntryName(createOccupiedNames({ files: ["page"] }), "page"),
|
|
110
|
+
).toBe("page-1")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("resolves file-vs-directory prefix collisions on the colliding segment", () => {
|
|
114
|
+
expect(
|
|
115
|
+
uniqueEntryName(createOccupiedNames({ files: ["x"] }), "x/y.txt"),
|
|
116
|
+
).toBe("x-1/y.txt")
|
|
117
|
+
expect(
|
|
118
|
+
uniqueEntryName(createOccupiedNames({ files: ["a/b"] }), "a/b/c.txt"),
|
|
119
|
+
).toBe("a/b-1/c.txt")
|
|
120
|
+
expect(
|
|
121
|
+
uniqueEntryName(createOccupiedNames({ files: ["x", "x-1"] }), "x/y.txt"),
|
|
122
|
+
).toBe("x-2/y.txt")
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("directory ancestors do not collide with nested entries", () => {
|
|
126
|
+
const occupied = createOccupiedNames()
|
|
127
|
+
occupyEntryName(occupied, "src/a.txt")
|
|
128
|
+
expect(uniqueEntryName(occupied, "src/sub/b.txt")).toBe("src/sub/b.txt")
|
|
129
|
+
expect(uniqueEntryName(occupied, "src/c.txt")).toBe("src/c.txt")
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test("an installed file blocks the exact path of a later directory root", () => {
|
|
133
|
+
const occupied = createOccupiedNames()
|
|
134
|
+
occupyEntryName(occupied, "x/y.txt")
|
|
135
|
+
expect(occupied.dirs.has("x")).toBe(true)
|
|
136
|
+
expect(uniqueEntryName(occupied, "x")).toBe("x-1")
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test("suffixing retries until the whole path is free", () => {
|
|
140
|
+
const occupied = createOccupiedNames({ files: ["a.png", "a-1.png"] })
|
|
141
|
+
expect(uniqueEntryName(occupied, "a.png")).toBe("a-2.png")
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test("untouched names pass through", () => {
|
|
145
|
+
expect(
|
|
146
|
+
uniqueEntryName(createOccupiedNames({ files: ["other.png"] }), "a.png"),
|
|
147
|
+
).toBe("a.png")
|
|
148
|
+
})
|
|
149
|
+
})
|