@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
package/src/hash.test.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { Readable } from "node:stream"
|
|
6
|
+
import sharp from "sharp"
|
|
7
|
+
import { describe, expect, test } from "vitest"
|
|
8
|
+
import { createDirectoryResourceAPI } from "./directory-api.ts"
|
|
9
|
+
import {
|
|
10
|
+
computeDHash,
|
|
11
|
+
computePHash,
|
|
12
|
+
grayStddev,
|
|
13
|
+
hashStream,
|
|
14
|
+
PHASH_GRID,
|
|
15
|
+
} from "./hash.ts"
|
|
16
|
+
|
|
17
|
+
function grayGrid(values: readonly number[], grid = PHASH_GRID): Uint8Array {
|
|
18
|
+
return Uint8Array.from({ length: grid * grid }, (_, i) => values[i] ?? 0)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("computeDHash", () => {
|
|
22
|
+
test("is deterministic and returns 16 lowercase hex chars", () => {
|
|
23
|
+
const gray = grayGrid(
|
|
24
|
+
Array.from({ length: PHASH_GRID * PHASH_GRID }, (_, i) => i % 256),
|
|
25
|
+
)
|
|
26
|
+
const first = computeDHash(gray)
|
|
27
|
+
expect(first).toBe(computeDHash(gray))
|
|
28
|
+
expect(first).toMatch(/^[0-9a-f]{16}$/)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("constant image hashes to all-zero bits", () => {
|
|
32
|
+
expect(computeDHash(grayGrid([]))).toBe("0000000000000000")
|
|
33
|
+
// Regression: the old implementation compared the last sample
|
|
34
|
+
// against out-of-bounds black, pinning the final bit of every row
|
|
35
|
+
// to 1 even for a full-bright image.
|
|
36
|
+
expect(computeDHash(grayGrid(Array(1024).fill(255)))).toBe(
|
|
37
|
+
"0000000000000000",
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test("left-to-right gradient differs from right-to-left", () => {
|
|
42
|
+
// Boundary at 14 sits between adjacent samples (12 and 16) so the
|
|
43
|
+
// comparison grid can see the direction of the transition.
|
|
44
|
+
const leftDark = grayGrid(
|
|
45
|
+
Array.from({ length: PHASH_GRID * PHASH_GRID }, (_, i) => {
|
|
46
|
+
const x = i % PHASH_GRID
|
|
47
|
+
return x < 14 ? 0 : 255
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
const rightDark = grayGrid(
|
|
51
|
+
Array.from({ length: PHASH_GRID * PHASH_GRID }, (_, i) => {
|
|
52
|
+
const x = i % PHASH_GRID
|
|
53
|
+
return x < 14 ? 255 : 0
|
|
54
|
+
}),
|
|
55
|
+
)
|
|
56
|
+
expect(computeDHash(leftDark)).not.toBe(computeDHash(rightDark))
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe("computePHash", () => {
|
|
61
|
+
test("is deterministic and returns 16 lowercase hex chars", () => {
|
|
62
|
+
const gray = grayGrid(
|
|
63
|
+
Array.from({ length: PHASH_GRID * PHASH_GRID }, (_, i) => (i * 7) % 256),
|
|
64
|
+
)
|
|
65
|
+
const first = computePHash(gray)
|
|
66
|
+
expect(first).toBe(computePHash(gray))
|
|
67
|
+
expect(first).toMatch(/^[0-9a-f]{16}$/)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("constant image hashes to all-zero bits", () => {
|
|
71
|
+
const gray = grayGrid([])
|
|
72
|
+
expect(computePHash(gray)).toBe("0000000000000000")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("nearly identical images differ in only a few bits", () => {
|
|
76
|
+
const base = Array.from(
|
|
77
|
+
{ length: PHASH_GRID * PHASH_GRID },
|
|
78
|
+
(_, i) => (i * 13 + (i % 7)) % 256,
|
|
79
|
+
)
|
|
80
|
+
const baseHash = computePHash(grayGrid(base))
|
|
81
|
+
const noisy = base.map((v, i) => (i % 97 === 0 ? (v + 3) % 256 : v))
|
|
82
|
+
const noisyHash = computePHash(grayGrid(noisy))
|
|
83
|
+
const distance = popcount(
|
|
84
|
+
BigInt(`0x${baseHash}`) ^ BigInt(`0x${noisyHash}`),
|
|
85
|
+
)
|
|
86
|
+
expect(distance).toBeLessThan(8)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe("grayStddev", () => {
|
|
91
|
+
test("is zero for constant buffers", () => {
|
|
92
|
+
expect(grayStddev(grayGrid([]))).toBe(0)
|
|
93
|
+
expect(grayStddev(grayGrid(Array(1024).fill(255)))).toBe(0)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test("measures spread around the mean", () => {
|
|
97
|
+
const half = grayGrid([...Array(512).fill(0), ...Array(512).fill(255)])
|
|
98
|
+
expect(grayStddev(half)).toBeCloseTo(127.5, 5)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("low-contrast ramps stay below the flat threshold", () => {
|
|
102
|
+
const ramp = grayGrid(
|
|
103
|
+
Array.from({ length: PHASH_GRID * PHASH_GRID }, (_, i) => {
|
|
104
|
+
const x = i % PHASH_GRID
|
|
105
|
+
return 100 + Math.floor((x * 20) / PHASH_GRID)
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
expect(grayStddev(ramp)).toBeLessThan(8)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe("hashStream", () => {
|
|
113
|
+
test("hashes streamed bytes like node:crypto", async () => {
|
|
114
|
+
const stream = Readable.from([Buffer.from("hello "), Buffer.from("world")])
|
|
115
|
+
expect(await hashStream(stream, "sha256")).toBe(
|
|
116
|
+
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
function popcount(v: bigint): number {
|
|
122
|
+
let count = 0
|
|
123
|
+
while (v > 0n) {
|
|
124
|
+
v &= v - 1n
|
|
125
|
+
count++
|
|
126
|
+
}
|
|
127
|
+
return count
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── End-to-end through the ResourceAPI decode pipeline ──────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Deterministic luminance structure: smooth R/G ramps plus a slow sine
|
|
134
|
+
* ripple (period scales with the image, so it stays coherent through
|
|
135
|
+
* the 32×32 hash-grid downsample — a fixed-pixel checkerboard would
|
|
136
|
+
* alias differently across resize paths). The horizontal comparison
|
|
137
|
+
* bits of dHash track the R ramp direction, so `flipX` inverts nearly
|
|
138
|
+
* every bit — a clean "different content" pair.
|
|
139
|
+
*/
|
|
140
|
+
function makeGradient(
|
|
141
|
+
width: number,
|
|
142
|
+
height: number,
|
|
143
|
+
opts: { readonly flipX?: boolean } = {},
|
|
144
|
+
): Buffer {
|
|
145
|
+
const bytes = Buffer.alloc(width * height * 3)
|
|
146
|
+
const period = width / 16
|
|
147
|
+
for (let y = 0; y < height; y++) {
|
|
148
|
+
for (let x = 0; x < width; x++) {
|
|
149
|
+
const i = (y * width + x) * 3
|
|
150
|
+
const ramp =
|
|
151
|
+
opts.flipX === true ? 255 - (x * 255) / width : (x * 255) / width
|
|
152
|
+
bytes[i] = Math.round(ramp)
|
|
153
|
+
bytes[i + 1] = Math.round((y * 255) / height)
|
|
154
|
+
bytes[i + 2] = Math.round(128 + 96 * Math.sin((x + y) / period))
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return bytes
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function writePng(
|
|
161
|
+
dir: string,
|
|
162
|
+
name: string,
|
|
163
|
+
width: number,
|
|
164
|
+
height: number,
|
|
165
|
+
opts: { readonly flipX?: boolean } = {},
|
|
166
|
+
): Promise<string> {
|
|
167
|
+
const path = join(dir, name)
|
|
168
|
+
await sharp(makeGradient(width, height, opts), {
|
|
169
|
+
raw: { width, height, channels: 3 },
|
|
170
|
+
})
|
|
171
|
+
.png()
|
|
172
|
+
.toFile(path)
|
|
173
|
+
return path
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A solid-color PNG — low-information content the hash path must skip. */
|
|
177
|
+
async function flatPng(): Promise<Buffer> {
|
|
178
|
+
return sharp(Buffer.alloc(64 * 64 * 3, 200), {
|
|
179
|
+
raw: { width: 64, height: 64, channels: 3 },
|
|
180
|
+
})
|
|
181
|
+
.png()
|
|
182
|
+
.toBuffer()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function hamming(a: string, b: string): number {
|
|
186
|
+
return popcount(BigInt(`0x${a}`) ^ BigInt(`0x${b}`))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
describe("computeImageHashes end-to-end (real sharp decode)", () => {
|
|
190
|
+
let dir: string
|
|
191
|
+
|
|
192
|
+
test.beforeEach(() => {
|
|
193
|
+
dir = mkdtempSync(join(tmpdir(), "hash-e2e-"))
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test.afterEach(() => {
|
|
197
|
+
rmSync(dir, { recursive: true, force: true })
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test("hashes a real PNG deterministically with the expected formats", async () => {
|
|
201
|
+
await writePng(dir, "a.png", 400, 300)
|
|
202
|
+
const api = createDirectoryResourceAPI(dir)
|
|
203
|
+
|
|
204
|
+
const first = await api.computeImageHashes("a.png", [
|
|
205
|
+
"sha256",
|
|
206
|
+
"dhash",
|
|
207
|
+
"phash",
|
|
208
|
+
])
|
|
209
|
+
const second = await api.computeImageHashes("a.png", [
|
|
210
|
+
"sha256",
|
|
211
|
+
"dhash",
|
|
212
|
+
"phash",
|
|
213
|
+
])
|
|
214
|
+
|
|
215
|
+
expect(first).toEqual(second)
|
|
216
|
+
expect(first?.sha256).toMatch(/^[0-9a-f]{64}$/)
|
|
217
|
+
expect(first?.dhash).toMatch(/^[0-9a-f]{16}$/)
|
|
218
|
+
expect(first?.phash).toMatch(/^[0-9a-f]{16}$/)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test("scaled + recompressed copies stay perceptually close", async () => {
|
|
222
|
+
await writePng(dir, "a.png", 400, 300)
|
|
223
|
+
// Downscaled, recompressed JPEG of the same image.
|
|
224
|
+
const recompressed = await sharp(makeGradient(400, 300), {
|
|
225
|
+
raw: { width: 400, height: 300, channels: 3 },
|
|
226
|
+
})
|
|
227
|
+
.resize(200, 150)
|
|
228
|
+
.jpeg({ quality: 60 })
|
|
229
|
+
.toBuffer()
|
|
230
|
+
writeFileSync(join(dir, "b.jpg"), recompressed)
|
|
231
|
+
const api = createDirectoryResourceAPI(dir)
|
|
232
|
+
|
|
233
|
+
const a = await api.computeImageHashes("a.png", [
|
|
234
|
+
"sha256",
|
|
235
|
+
"dhash",
|
|
236
|
+
"phash",
|
|
237
|
+
])
|
|
238
|
+
const b = await api.computeImageHashes("b.jpg", [
|
|
239
|
+
"sha256",
|
|
240
|
+
"dhash",
|
|
241
|
+
"phash",
|
|
242
|
+
])
|
|
243
|
+
|
|
244
|
+
// Byte-exact hash must differ (re-encoding changed the bytes)…
|
|
245
|
+
expect(a?.sha256).not.toBe(b?.sha256)
|
|
246
|
+
// …but both perceptual hashes stay within the similarity bounds
|
|
247
|
+
// (mirrors SIMILAR_MAX_DISTANCE_BY_TYPE in the server hash service).
|
|
248
|
+
expect(hamming(a?.dhash ?? "", b?.dhash ?? "")).toBeLessThanOrEqual(8)
|
|
249
|
+
expect(hamming(a?.phash ?? "", b?.phash ?? "")).toBeLessThanOrEqual(6)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test("flat images yield no perceptual hashes but keep sha256", async () => {
|
|
253
|
+
writeFileSync(join(dir, "flat.png"), await flatPng())
|
|
254
|
+
const api = createDirectoryResourceAPI(dir)
|
|
255
|
+
|
|
256
|
+
await expect(
|
|
257
|
+
api.computeImageHashes("flat.png", ["dhash", "phash"]),
|
|
258
|
+
).resolves.toBeUndefined()
|
|
259
|
+
|
|
260
|
+
const withSha = await api.computeImageHashes("flat.png", [
|
|
261
|
+
"sha256",
|
|
262
|
+
"dhash",
|
|
263
|
+
"phash",
|
|
264
|
+
])
|
|
265
|
+
expect(withSha?.sha256).toMatch(/^[0-9a-f]{64}$/)
|
|
266
|
+
expect(withSha?.dhash).toBeUndefined()
|
|
267
|
+
expect(withSha?.phash).toBeUndefined()
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test("different content lands far away in perceptual space", async () => {
|
|
271
|
+
await writePng(dir, "a.png", 400, 300)
|
|
272
|
+
await writePng(dir, "b.png", 400, 300, { flipX: true })
|
|
273
|
+
const api = createDirectoryResourceAPI(dir)
|
|
274
|
+
|
|
275
|
+
const a = await api.computeImageHashes("a.png", ["dhash"])
|
|
276
|
+
const b = await api.computeImageHashes("b.png", ["dhash"])
|
|
277
|
+
|
|
278
|
+
expect(hamming(a?.dhash ?? "", b?.dhash ?? "")).toBeGreaterThan(8)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
test("non-image files resolve to undefined for any kinds", async () => {
|
|
282
|
+
writeFileSync(join(dir, "note.txt"), "hello")
|
|
283
|
+
const api = createDirectoryResourceAPI(dir)
|
|
284
|
+
|
|
285
|
+
await expect(
|
|
286
|
+
api.computeImageHashes("note.txt", ["dhash"]),
|
|
287
|
+
).resolves.toBeUndefined()
|
|
288
|
+
await expect(
|
|
289
|
+
api.computeImageHashes("note.txt", ["sha256", "dhash"]),
|
|
290
|
+
).resolves.toBeUndefined()
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test("hashBytes matches node:crypto on the raw bytes", async () => {
|
|
294
|
+
await writePng(dir, "a.png", 64, 64)
|
|
295
|
+
const api = createDirectoryResourceAPI(dir)
|
|
296
|
+
const expected = createHash("sha256")
|
|
297
|
+
.update(readFileSync(join(dir, "a.png")))
|
|
298
|
+
.digest("hex")
|
|
299
|
+
await expect(api.hashBytes("a.png", "sha256")).resolves.toBe(expected)
|
|
300
|
+
})
|
|
301
|
+
})
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import type { Readable } from "node:stream"
|
|
3
|
+
import type { ImageHashKind } from "@hoardodile/sdk-types"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Content hashing for the `imageHashes` plugin hook. All perceptual
|
|
7
|
+
* hashes derive from one 32×32 grayscale rendition (single sharp
|
|
8
|
+
* decode), so requesting several kinds costs one decode:
|
|
9
|
+
* - `sha256`: exact digest of the raw entry bytes (streamed).
|
|
10
|
+
* - `dhash`: difference hash — 9 samples per row of the 32×32 grid
|
|
11
|
+
* (nearest-neighbour spread), adjacent pairs compared, equivalent to
|
|
12
|
+
* the standard 9×8 dHash.
|
|
13
|
+
* - `phash`: standard DCT-based perceptual hash — the 32×32 grid is
|
|
14
|
+
* mean-pooled to 8×8, then the 8×8 DCT coefficients are thresholded
|
|
15
|
+
* at the median.
|
|
16
|
+
*
|
|
17
|
+
* The 64-bit values are hex strings (16 chars), compared by Hamming
|
|
18
|
+
* distance in `@hoardodile/sdk-server` / the server's hash service.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Side of the grayscale grid every perceptual hash is derived from. */
|
|
22
|
+
export const PHASH_GRID = 32
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Minimum grayscale standard deviation (0–255 scale) for an image to
|
|
26
|
+
* yield perceptual hashes. Near-flat images (solid fills, blank pages)
|
|
27
|
+
* hash to distance-0 neighbours of every other flat image, so they are
|
|
28
|
+
* skipped — `sha256` exact hashing is unaffected.
|
|
29
|
+
*/
|
|
30
|
+
export const MIN_PERCEPTUAL_STDDEV = 8
|
|
31
|
+
|
|
32
|
+
/** Stream `stream` through a digest of `algo` and resolve its lowercase hex. */
|
|
33
|
+
export function hashStream(
|
|
34
|
+
stream: Readable,
|
|
35
|
+
algo: "md5" | "sha256",
|
|
36
|
+
): Promise<string> {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const hash = createHash(algo)
|
|
39
|
+
stream.on("data", (chunk: string | Buffer) => hash.update(chunk))
|
|
40
|
+
stream.on("end", () => resolve(hash.digest("hex")))
|
|
41
|
+
stream.on("error", reject)
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* dHash over a `PHASH_GRID × PHASH_GRID` grayscale buffer: 9 samples per
|
|
47
|
+
* row (nearest-neighbour spread across the width), each of 8×8 bits
|
|
48
|
+
* comparing a sample against its right neighbour, packed MSB-first into
|
|
49
|
+
* a 16-char hex string.
|
|
50
|
+
*/
|
|
51
|
+
export function computeDHash(gray: Uint8Array, grid = PHASH_GRID): string {
|
|
52
|
+
let bits = 0n
|
|
53
|
+
for (let row = 0; row < 8; row++) {
|
|
54
|
+
for (let col = 0; col < 8; col++) {
|
|
55
|
+
const left = sampleAt(gray, grid, row, col)
|
|
56
|
+
const right = sampleAt(gray, grid, row, col + 1)
|
|
57
|
+
bits = (bits << 1n) | BigInt(left > right ? 1 : 0)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return bits.toString(16).padStart(16, "0")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Grayscale value at grid position (row, col), clamped to the grid. */
|
|
64
|
+
function sampleAt(
|
|
65
|
+
gray: Uint8Array,
|
|
66
|
+
grid: number,
|
|
67
|
+
row: number,
|
|
68
|
+
col: number,
|
|
69
|
+
): number {
|
|
70
|
+
const x = Math.round((col * (grid - 1)) / 8)
|
|
71
|
+
return gray[row * grid + x] ?? 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* pHash over a `PHASH_GRID × PHASH_GRID` grayscale buffer: the grid is
|
|
76
|
+
* mean-pooled to 8×8, then the 8×8 type-II DCT coefficients (precomputed
|
|
77
|
+
* cosine tables) are thresholded at their median, each bit MSB-first
|
|
78
|
+
* into a 16-char hex string.
|
|
79
|
+
*/
|
|
80
|
+
export function computePHash(gray: Uint8Array, grid = PHASH_GRID): string {
|
|
81
|
+
const n = 8
|
|
82
|
+
const small = meanPool(gray, grid, n)
|
|
83
|
+
const cos = precomputeCosines(n, n)
|
|
84
|
+
const coefficients: number[] = []
|
|
85
|
+
for (let v = 0; v < n; v++) {
|
|
86
|
+
for (let u = 0; u < n; u++) {
|
|
87
|
+
let sum = 0
|
|
88
|
+
for (let y = 0; y < n; y++) {
|
|
89
|
+
const cosYv = cos[v]?.[y] ?? 0
|
|
90
|
+
const cosXu = cos[u] ?? []
|
|
91
|
+
let rowSum = 0
|
|
92
|
+
for (let x = 0; x < n; x++) {
|
|
93
|
+
rowSum += (small[y * n + x] ?? 0) * (cosXu[x] ?? 0)
|
|
94
|
+
}
|
|
95
|
+
sum += rowSum * cosYv
|
|
96
|
+
}
|
|
97
|
+
const cu = u === 0 ? 1 / Math.SQRT2 : 1
|
|
98
|
+
const cv = v === 0 ? 1 / Math.SQRT2 : 1
|
|
99
|
+
coefficients.push(0.25 * cu * cv * sum)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const median = medianOf(coefficients)
|
|
103
|
+
let bits = 0n
|
|
104
|
+
for (const coefficient of coefficients) {
|
|
105
|
+
bits = (bits << 1n) | BigInt(coefficient > median ? 1 : 0)
|
|
106
|
+
}
|
|
107
|
+
return bits.toString(16).padStart(16, "0")
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Mean-pool a `grid × grid` buffer into an `n × n` grid of averages. */
|
|
111
|
+
function meanPool(gray: Uint8Array, grid: number, n: number): number[] {
|
|
112
|
+
const step = grid / n
|
|
113
|
+
const pooled: number[] = []
|
|
114
|
+
for (let by = 0; by < n; by++) {
|
|
115
|
+
for (let bx = 0; bx < n; bx++) {
|
|
116
|
+
let sum = 0
|
|
117
|
+
for (let y = 0; y < step; y++) {
|
|
118
|
+
for (let x = 0; x < step; x++) {
|
|
119
|
+
sum += gray[(by * step + y) * grid + bx * step + x] ?? 0
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
pooled.push(sum / (step * step))
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return pooled
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Population standard deviation of a grayscale buffer. Perceptual hashes
|
|
130
|
+
* of near-flat images (solid fills, blank pages) cluster at distance 0
|
|
131
|
+
* regardless of content — callers gate hash emission on this measure.
|
|
132
|
+
*/
|
|
133
|
+
export function grayStddev(gray: Uint8Array): number {
|
|
134
|
+
if (gray.length === 0) return 0
|
|
135
|
+
let mean = 0
|
|
136
|
+
for (const value of gray) mean += value
|
|
137
|
+
mean /= gray.length
|
|
138
|
+
let squared = 0
|
|
139
|
+
for (const value of gray) {
|
|
140
|
+
const delta = value - mean
|
|
141
|
+
squared += delta * delta
|
|
142
|
+
}
|
|
143
|
+
return Math.sqrt(squared / gray.length)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function precomputeCosines(grid: number, n: number): number[][] {
|
|
147
|
+
const cosX: number[][] = []
|
|
148
|
+
for (let k = 0; k < n; k++) {
|
|
149
|
+
const row: number[] = []
|
|
150
|
+
for (let x = 0; x < grid; x++) {
|
|
151
|
+
row.push(Math.cos(((2 * x + 1) * k * Math.PI) / (2 * grid)))
|
|
152
|
+
}
|
|
153
|
+
cosX.push(row)
|
|
154
|
+
}
|
|
155
|
+
return cosX
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function medianOf(values: readonly number[]): number {
|
|
159
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
160
|
+
return sorted[Math.floor(sorted.length / 2)] ?? 0
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Kinds whose values come from the single grayscale decode. */
|
|
164
|
+
export const PERCEPTUAL_HASH_KINDS: readonly ImageHashKind[] = [
|
|
165
|
+
"dhash",
|
|
166
|
+
"phash",
|
|
167
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared directory-size walk: the one implementation of "recursive byte
|
|
3
|
+
* size of a directory tree" for host storage. Consumers opt into
|
|
4
|
+
* different file policies via options:
|
|
5
|
+
*
|
|
6
|
+
* - vault totals skip dot-prefixed files (host temp staging), while
|
|
7
|
+
* storage totals count everything — see {@link vaultTotalSize} and
|
|
8
|
+
* `apps/server/src/domain/storage/service.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Symbolic links are skipped so cycles cannot hang the walk and
|
|
11
|
+
* linked-out files are not double counted; missing/transient entries
|
|
12
|
+
* are ignored.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Dirent } from "node:fs"
|
|
16
|
+
import { readdir, stat } from "node:fs/promises"
|
|
17
|
+
import { join } from "node:path"
|
|
18
|
+
|
|
19
|
+
export type DirSizeOptions = {
|
|
20
|
+
/** Skip file names starting with a dot (host temp staging). */
|
|
21
|
+
readonly excludeDotPrefix?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function sumDirSizes(
|
|
25
|
+
root: string,
|
|
26
|
+
opts: DirSizeOptions = {},
|
|
27
|
+
): Promise<number> {
|
|
28
|
+
let total = 0
|
|
29
|
+
const stack = [root]
|
|
30
|
+
while (stack.length > 0) {
|
|
31
|
+
const dir = stack.pop()
|
|
32
|
+
if (dir === undefined) continue
|
|
33
|
+
let entries: Dirent[]
|
|
34
|
+
try {
|
|
35
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
36
|
+
} catch {
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (entry.isSymbolicLink()) continue
|
|
41
|
+
const full = join(dir, entry.name)
|
|
42
|
+
if (entry.isDirectory()) {
|
|
43
|
+
stack.push(full)
|
|
44
|
+
} else if (entry.isFile()) {
|
|
45
|
+
if (opts.excludeDotPrefix === true && entry.name.startsWith(".")) {
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const info = await stat(full)
|
|
50
|
+
total += info.size
|
|
51
|
+
} catch {
|
|
52
|
+
// File disappeared between readdir and stat.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return total
|
|
58
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hoard storage runtime: layout authority, versioned-write gate,
|
|
3
|
+
* version directory mechanics, zip extract + bulk export helpers, and
|
|
4
|
+
* the upload staging pool. Shared by the server, the CLI and future
|
|
5
|
+
* shells so every consumer works against one storage implementation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type { ZipStreamEntry } from "../archive/index.ts"
|
|
9
|
+
export {
|
|
10
|
+
extractArchiveInto,
|
|
11
|
+
listArchiveEntries,
|
|
12
|
+
streamStoredZip,
|
|
13
|
+
validateArchiveBudget,
|
|
14
|
+
} from "../archive/index.ts"
|
|
15
|
+
export type { DirSizeOptions } from "./dir-size.ts"
|
|
16
|
+
export { sumDirSizes } from "./dir-size.ts"
|
|
17
|
+
export {
|
|
18
|
+
naturalSort,
|
|
19
|
+
ORDER_MANIFEST_NAME,
|
|
20
|
+
orderEntries,
|
|
21
|
+
orderManifestPath,
|
|
22
|
+
parseOrderManifest,
|
|
23
|
+
readOrderManifest,
|
|
24
|
+
writeOrderManifest,
|
|
25
|
+
} from "./order-manifest.ts"
|
|
26
|
+
export type {
|
|
27
|
+
CreateStoragePathsOptions,
|
|
28
|
+
LocalPaths,
|
|
29
|
+
StoragePaths,
|
|
30
|
+
VersionPaths,
|
|
31
|
+
} from "./paths.ts"
|
|
32
|
+
export {
|
|
33
|
+
assertInside,
|
|
34
|
+
assertSafeSegment,
|
|
35
|
+
createStoragePaths,
|
|
36
|
+
imageVariantKey,
|
|
37
|
+
RESOURCE_DATA_DIR_NAME,
|
|
38
|
+
} from "./paths.ts"
|
|
39
|
+
export {
|
|
40
|
+
commitVaultFile,
|
|
41
|
+
discardVaultTempFile,
|
|
42
|
+
PluginVaultPathError,
|
|
43
|
+
parsePluginVaultDest,
|
|
44
|
+
type VaultCommitResult,
|
|
45
|
+
vaultFileSha256,
|
|
46
|
+
vaultReadFile,
|
|
47
|
+
vaultRemoveFile,
|
|
48
|
+
vaultStatFile,
|
|
49
|
+
vaultTempFile,
|
|
50
|
+
vaultTotalSize,
|
|
51
|
+
} from "./plugin-vault.ts"
|
|
52
|
+
export type { OccupiedNames } from "./sanitize.ts"
|
|
53
|
+
export {
|
|
54
|
+
createOccupiedNames,
|
|
55
|
+
occupyEntryName,
|
|
56
|
+
sanitizeEntryName,
|
|
57
|
+
uniqueEntryName,
|
|
58
|
+
} from "./sanitize.ts"
|
|
59
|
+
export {
|
|
60
|
+
findStagedArchiveFile,
|
|
61
|
+
findStagedPoolFile,
|
|
62
|
+
removeStagedPoolFile,
|
|
63
|
+
resolveStagedPoolFiles,
|
|
64
|
+
writeStagedArchiveFile,
|
|
65
|
+
writeStagedPoolFile,
|
|
66
|
+
} from "./staging-dir.ts"
|
|
67
|
+
export type { CreateNextVersionResult } from "./version.ts"
|
|
68
|
+
export {
|
|
69
|
+
createNextVersion,
|
|
70
|
+
currentVersion,
|
|
71
|
+
ensureBootstrapVersion,
|
|
72
|
+
listVersions,
|
|
73
|
+
readActiveVersion,
|
|
74
|
+
versionedDbFile,
|
|
75
|
+
versionedPath,
|
|
76
|
+
writeActiveVersion,
|
|
77
|
+
} from "./version.ts"
|
|
78
|
+
export type { VersionedFolderOps } from "./versioned-folder-ops.ts"
|
|
79
|
+
export {
|
|
80
|
+
archiveStaleFiles,
|
|
81
|
+
buildVersionedFolderOps,
|
|
82
|
+
} from "./versioned-folder-ops.ts"
|
|
83
|
+
export type { VersionedWriteCommand } from "./write-versioned.ts"
|
|
84
|
+
export { writeVersioned } from "./write-versioned.ts"
|