pinokiod 8.0.39 → 8.0.41
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/kernel/api/hf/index.js +2 -2
- package/kernel/api/index.js +12 -3
- package/kernel/connect/index.js +2 -2
- package/kernel/connect/providers/huggingface/index.js +14 -5
- package/kernel/index.js +14 -2
- package/kernel/shell.js +3 -2
- package/kernel/shells.js +6 -0
- package/kernel/vault/constants.js +13 -0
- package/kernel/vault/hash_worker.js +9 -2
- package/kernel/vault/index.js +1856 -316
- package/kernel/vault/registry.js +526 -52
- package/kernel/vault/snapshot.js +29 -0
- package/kernel/vault/sweeper.js +480 -210
- package/kernel/vault/walker.js +142 -0
- package/package.json +1 -1
- package/server/index.js +79 -90
- package/server/lib/privacy_filter_cache.js +4 -1
- package/server/public/storage-size.js +12 -0
- package/server/public/style.css +13 -0
- package/server/public/tab-link-popover.js +3 -0
- package/server/public/vault-nav.js +96 -0
- package/server/public/vault.css +1079 -0
- package/server/public/vault.js +1835 -0
- package/server/views/app.ejs +93 -16
- package/server/views/connect/huggingface.ejs +7 -9
- package/server/views/partials/main_sidebar.ejs +6 -1
- package/server/views/partials/vault_workspace.ejs +55 -0
- package/server/views/vault.ejs +5 -1532
- package/server/views/vault_app.ejs +22 -0
- package/test/hf-api.test.js +4 -2
- package/test/huggingface-connect.test.js +7 -11
- package/test/huggingface-token-validity.test.js +135 -0
- package/test/plugin-action-functions.test.js +19 -0
- package/test/privacy-filter-cache.test.js +1 -0
- package/test/vault-engine.test.js +1276 -26
- package/test/vault-sweep.test.js +1043 -35
- package/test/vault-ui.test.js +2184 -65
package/test/vault-sweep.test.js
CHANGED
|
@@ -5,6 +5,8 @@ const os = require('os')
|
|
|
5
5
|
const path = require('path')
|
|
6
6
|
const crypto = require('crypto')
|
|
7
7
|
const Vault = require('../kernel/vault')
|
|
8
|
+
const Registry = require('../kernel/vault/registry')
|
|
9
|
+
const { walkBatches } = require('../kernel/vault/walker')
|
|
8
10
|
|
|
9
11
|
const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex')
|
|
10
12
|
|
|
@@ -37,6 +39,77 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
37
39
|
}
|
|
38
40
|
})
|
|
39
41
|
|
|
42
|
+
test('directory traversal yields bounded entry chunks without skipping nested files', async () => {
|
|
43
|
+
const root = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-walk-'))
|
|
44
|
+
homes.push(root)
|
|
45
|
+
const expected = []
|
|
46
|
+
for (let index = 0; index < 7; index++) {
|
|
47
|
+
expected.push(await writeFile(path.resolve(root, `dir-${index % 2}`, `file-${index}.bin`), String(index)))
|
|
48
|
+
}
|
|
49
|
+
const found = []
|
|
50
|
+
let dirs = 0
|
|
51
|
+
for await (const batch of walkBatches(root, { concurrency: 2, entryBatchSize: 2 })) {
|
|
52
|
+
for (const group of batch) {
|
|
53
|
+
if (group.entries) assert.ok(group.entries.length <= 2)
|
|
54
|
+
if (group.firstChunk) dirs += 1
|
|
55
|
+
found.push(...group.files.map((file) => file.path))
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
assert.deepStrictEqual(found.sort(), expected.sort())
|
|
59
|
+
assert.strictEqual(dirs, 3)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('directory traversal resolves unknown entry types before deciding whether to recurse', async () => {
|
|
63
|
+
const root = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-walk-unknown-'))
|
|
64
|
+
homes.push(root)
|
|
65
|
+
const expected = await writeFile(path.resolve(root, 'nested', 'file.bin'), 'data')
|
|
66
|
+
const realOpendir = fs.promises.opendir
|
|
67
|
+
fs.promises.opendir = async (dir, options) => {
|
|
68
|
+
const handle = await realOpendir(dir, options)
|
|
69
|
+
if (path.resolve(dir) !== root) return handle
|
|
70
|
+
return {
|
|
71
|
+
read: async () => {
|
|
72
|
+
const entry = await handle.read()
|
|
73
|
+
if (!entry || entry.name !== 'nested') return entry
|
|
74
|
+
return {
|
|
75
|
+
name: entry.name,
|
|
76
|
+
isDirectory: () => false,
|
|
77
|
+
isFile: () => false,
|
|
78
|
+
isSymbolicLink: () => false
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
close: () => handle.close()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const found = []
|
|
85
|
+
try {
|
|
86
|
+
for await (const batch of walkBatches(root, { concurrency: 2, entryBatchSize: 2 })) {
|
|
87
|
+
found.push(...batch.flatMap((group) => group.files.map((file) => file.path)))
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
fs.promises.opendir = realOpendir
|
|
91
|
+
}
|
|
92
|
+
assert.deepStrictEqual(found, [expected])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('directory traversal follows only an explicitly configured symlink root', async (t) => {
|
|
96
|
+
const realRoot = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-walk-real-'))
|
|
97
|
+
const linkRoot = path.resolve(os.tmpdir(), `pinokio-walk-link-${crypto.randomUUID()}`)
|
|
98
|
+
homes.push(realRoot, linkRoot)
|
|
99
|
+
await writeFile(path.resolve(realRoot, 'nested', 'file.bin'), 'data')
|
|
100
|
+
try {
|
|
101
|
+
await fs.promises.symlink(realRoot, linkRoot, process.platform === 'win32' ? 'junction' : 'dir')
|
|
102
|
+
} catch (error) {
|
|
103
|
+
t.skip(`directory links unavailable: ${error.message}`)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
const found = []
|
|
107
|
+
for await (const batch of walkBatches(linkRoot)) {
|
|
108
|
+
found.push(...batch.flatMap((group) => group.files.map((file) => file.path)))
|
|
109
|
+
}
|
|
110
|
+
assert.deepStrictEqual(found, [path.resolve(linkRoot, 'nested', 'file.bin')])
|
|
111
|
+
})
|
|
112
|
+
|
|
40
113
|
test('scan discovers, hashes, adopts — and records folder totals', async () => {
|
|
41
114
|
const { home, vault } = await makeEnv()
|
|
42
115
|
const content = crypto.randomBytes(4096)
|
|
@@ -48,10 +121,45 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
48
121
|
assert.ok(result.bytes_total >= content.length + 4, 'folder total includes small files')
|
|
49
122
|
assert.ok(vault.registry.lastScan && vault.registry.lastScan.bytes_total === result.bytes_total)
|
|
50
123
|
assert.ok(vault.registry.lastScan.duration_ms >= 0, 'scan duration is measured')
|
|
124
|
+
assert.ok(vault.registry.lastScan.count_duration_ms >= 0, 'count duration is measured')
|
|
51
125
|
assert.ok(vault.registry.lastScan.walk_duration_ms >= 0, 'walk duration is measured')
|
|
52
126
|
assert.ok(vault.registry.lastScan.hash_duration_ms >= 0, 'hash duration is measured')
|
|
53
127
|
assert.strictEqual(vault.registry.lastScan.hash_total, 1, 'hash work has a determinate total after walking')
|
|
54
|
-
assert.
|
|
128
|
+
assert.strictEqual(vault.sweeper.state.total_files, 2, 'scan progress uses an exact pre-count')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('an app-scoped scan walks only that app and preserves unrelated registry state', async () => {
|
|
132
|
+
const { home, vault } = await makeEnv()
|
|
133
|
+
const content = crypto.randomBytes(4096)
|
|
134
|
+
const appAFile = await writeFile(path.resolve(home, 'api', 'appA', 'model.bin'), content)
|
|
135
|
+
const appBFile = await writeFile(path.resolve(home, 'api', 'appB', 'model.bin'), content)
|
|
136
|
+
await fs.promises.mkdir(path.resolve(home, 'api', 'emptyApp'))
|
|
137
|
+
await vault.sweeper.scan()
|
|
138
|
+
const appA = vault.sources().find((source) => source.kind === 'app' && source.app === 'appA')
|
|
139
|
+
const emptyApp = vault.sources().find((source) => source.kind === 'app' && source.app === 'emptyApp')
|
|
140
|
+
assert.ok(appA)
|
|
141
|
+
assert.strictEqual(vault.registry.scanFor(emptyApp.id).files, 0,
|
|
142
|
+
'a global scan refreshes the baseline for an empty app')
|
|
143
|
+
assert.strictEqual(vault.registry.duplicates.has(appBFile), true)
|
|
144
|
+
const globalScan = JSON.parse(JSON.stringify(vault.registry.lastScan))
|
|
145
|
+
|
|
146
|
+
await fs.promises.unlink(appBFile)
|
|
147
|
+
const result = await vault.sweeper.scan(appA.id)
|
|
148
|
+
|
|
149
|
+
assert.strictEqual(result.files, 1)
|
|
150
|
+
assert.strictEqual(result.bytes_total, content.length)
|
|
151
|
+
assert.deepStrictEqual(vault.registry.lastScan, globalScan, 'app scan does not replace global totals')
|
|
152
|
+
assert.strictEqual(vault.registry.duplicates.has(appBFile), true,
|
|
153
|
+
'scoped verification does not reconcile another app')
|
|
154
|
+
assert.strictEqual(vault.registry.links.has(appAFile), true)
|
|
155
|
+
assert.strictEqual(vault.registry.scanFor(appA.id).files, 1)
|
|
156
|
+
assert.strictEqual(vault.registry.scanFor(appA.id).bytes_total, content.length)
|
|
157
|
+
await vault.registry.flush()
|
|
158
|
+
const snapshot = JSON.parse(await fs.promises.readFile(vault.registry.snapshotPath, 'utf8'))
|
|
159
|
+
assert.strictEqual(snapshot.scans[appA.id].last_complete.bytes_total, content.length)
|
|
160
|
+
const reloaded = new Registry(vault.root)
|
|
161
|
+
await reloaded.load()
|
|
162
|
+
assert.strictEqual(reloaded.scanFor(appA.id).bytes_total, content.length)
|
|
55
163
|
})
|
|
56
164
|
|
|
57
165
|
test('scans NEVER convert: every byte-identical copy is pending, even in HF caches', async () => {
|
|
@@ -71,9 +179,20 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
71
179
|
assert.strictEqual(events.filter((e) => e.kind === 'convert').length, 0, 'no conversion without a click')
|
|
72
180
|
assert.ok(events.some((e) => e.kind === 'found'))
|
|
73
181
|
|
|
74
|
-
|
|
75
|
-
|
|
182
|
+
let converted = 0
|
|
183
|
+
for (const scopeId of new Set([...vault.registry.duplicates.values()].map((entry) => entry.source_id))) {
|
|
184
|
+
const paths = [...vault.registry.duplicates]
|
|
185
|
+
.filter(([, entry]) => entry.source_id === scopeId)
|
|
186
|
+
.map(([filePath]) => filePath)
|
|
187
|
+
const summary = await vault.perform('deduplicate', { scope_id: scopeId, paths })
|
|
188
|
+
converted += summary.converted
|
|
189
|
+
}
|
|
190
|
+
assert.strictEqual(converted, 2)
|
|
76
191
|
assert.strictEqual((await fs.promises.stat(a)).ino, (await fs.promises.stat(b)).ino)
|
|
192
|
+
|
|
193
|
+
await vault.sweeper.scan()
|
|
194
|
+
assert.strictEqual(vault.registry.lastScan.hashed, 0,
|
|
195
|
+
'Vault-authored inode and ctime changes refresh the exact scan snapshots')
|
|
77
196
|
})
|
|
78
197
|
|
|
79
198
|
test('no name heuristics: files inside venv-like folders are found', async () => {
|
|
@@ -85,6 +204,26 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
85
204
|
assert.strictEqual((await fs.promises.stat(inVenv)).nlink, 2, 'adopted despite venv-ish path')
|
|
86
205
|
})
|
|
87
206
|
|
|
207
|
+
test('a tracked Hugging Face blob that changes below the threshold is rehashed', async () => {
|
|
208
|
+
const { home, vault } = await makeEnv()
|
|
209
|
+
const content = crypto.randomBytes(4096)
|
|
210
|
+
const hash = sha256(content)
|
|
211
|
+
const file = await writeFile(
|
|
212
|
+
path.resolve(home, 'api', 'appA', 'cache', 'models--org--model', 'blobs', hash), content)
|
|
213
|
+
await vault.sweeper.scan()
|
|
214
|
+
assert.strictEqual(vault.registry.links.has(file), true)
|
|
215
|
+
|
|
216
|
+
await fs.promises.truncate(file, 512)
|
|
217
|
+
await vault.sweeper.scan()
|
|
218
|
+
|
|
219
|
+
const changedHash = sha256(await fs.promises.readFile(file))
|
|
220
|
+
assert.strictEqual(vault.registry.links.get(file).hash, changedHash)
|
|
221
|
+
assert.strictEqual(vault.registry.scanIndex.get(file).hash, changedHash)
|
|
222
|
+
assert.strictEqual(vault.registry.blobs.has(hash), false)
|
|
223
|
+
assert.strictEqual(vault.registry.blobs.has(changedHash), true)
|
|
224
|
+
assert.strictEqual((await fs.promises.stat(file)).size, 512)
|
|
225
|
+
})
|
|
226
|
+
|
|
88
227
|
test('symlinked dirs and files are skipped; cycles cannot hang the walk', async () => {
|
|
89
228
|
const { home, vault } = await makeEnv()
|
|
90
229
|
const real = await writeFile(path.resolve(home, 'api', 'appA', 'models', 'real.bin'), crypto.randomBytes(4096))
|
|
@@ -93,42 +232,71 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
93
232
|
assert.strictEqual((await fs.promises.stat(real)).nlink, 2)
|
|
94
233
|
})
|
|
95
234
|
|
|
96
|
-
test('
|
|
235
|
+
test('a file replaced by a symlink during inspection is never followed', async (t) => {
|
|
236
|
+
const { home, vault } = await makeEnv()
|
|
237
|
+
const outsideRoot = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-scan-race-outside-'))
|
|
238
|
+
homes.push(outsideRoot)
|
|
239
|
+
const outside = await writeFile(path.resolve(outsideRoot, 'outside.bin'), crypto.randomBytes(4096))
|
|
240
|
+
const outsideHash = sha256(await fs.promises.readFile(outside))
|
|
241
|
+
const candidate = await writeFile(path.resolve(home, 'api', 'appA', 'candidate.bin'), crypto.randomBytes(4096))
|
|
242
|
+
const realLstat = fs.promises.lstat
|
|
243
|
+
let replaced = false
|
|
244
|
+
fs.promises.lstat = async (target, options) => {
|
|
245
|
+
if (!replaced && path.resolve(target) === candidate) {
|
|
246
|
+
replaced = true
|
|
247
|
+
await fs.promises.unlink(candidate)
|
|
248
|
+
try {
|
|
249
|
+
await fs.promises.symlink(outside, candidate, 'file')
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (error.code === 'EPERM' || error.code === 'EACCES') {
|
|
252
|
+
await fs.promises.copyFile(outside, candidate)
|
|
253
|
+
t.skip(`file symlinks unavailable: ${error.message}`)
|
|
254
|
+
} else {
|
|
255
|
+
throw error
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return realLstat(target, options)
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
await vault.sweeper.scan()
|
|
263
|
+
} finally {
|
|
264
|
+
fs.promises.lstat = realLstat
|
|
265
|
+
}
|
|
266
|
+
if (!replaced || !(await realLstat(candidate)).isSymbolicLink()) return
|
|
267
|
+
|
|
268
|
+
assert.strictEqual(vault.registry.scanIndex.has(candidate), false)
|
|
269
|
+
assert.strictEqual(vault.registry.blobs.has(outsideHash), false)
|
|
270
|
+
assert.strictEqual((await fs.promises.stat(outside)).nlink, 1)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('filesystem links under api are never treated as external scan sources', async (t) => {
|
|
97
274
|
const { home, vault } = await makeEnv()
|
|
98
275
|
const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-external-'))
|
|
99
|
-
|
|
100
|
-
homes.push(external, nested)
|
|
276
|
+
homes.push(external)
|
|
101
277
|
const content = crypto.randomBytes(4096)
|
|
102
278
|
const local = await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), content)
|
|
103
279
|
const imported = await writeFile(path.resolve(external, 'models', 'm.bin'), content)
|
|
104
|
-
const nestedFile = await writeFile(path.resolve(nested, 'should-not-scan.bin'), crypto.randomBytes(4096))
|
|
105
280
|
try {
|
|
106
281
|
await fs.promises.symlink(external, path.resolve(home, 'api', 'linked-models'), process.platform === 'win32' ? 'junction' : 'dir')
|
|
107
|
-
await fs.promises.symlink(nested, path.resolve(external, 'nested-link'), process.platform === 'win32' ? 'junction' : 'dir')
|
|
108
282
|
} catch (error) {
|
|
109
283
|
t.skip(`directory links unavailable: ${error.message}`)
|
|
110
284
|
return
|
|
111
285
|
}
|
|
112
286
|
|
|
113
287
|
await vault.sweeper.scan()
|
|
114
|
-
const source = vault.sources().find((item) => item.kind === 'external' && item.label === 'linked-models')
|
|
115
288
|
const canonicalImported = path.resolve(await fs.promises.realpath(external), 'models', 'm.bin')
|
|
116
|
-
|
|
117
|
-
assert.
|
|
118
|
-
assert.strictEqual(source.parent_id, 'external')
|
|
119
|
-
assert.ok(vault.registry.scanIndex.has(canonicalImported), 'external root was scanned')
|
|
120
|
-
assert.strictEqual(vault.registry.scanIndex.has(canonicalNestedFile), false, 'nested directory link was not followed')
|
|
121
|
-
assert.strictEqual(vault.registry.duplicates.get(canonicalImported).source_id, source.id)
|
|
289
|
+
assert.strictEqual(vault.sources().some((item) => item.kind === 'external'), false)
|
|
290
|
+
assert.strictEqual(vault.registry.scanIndex.has(canonicalImported), false)
|
|
122
291
|
assert.strictEqual((await fs.promises.stat(local)).nlink, 2)
|
|
123
292
|
|
|
124
293
|
const status = await vault.status()
|
|
125
|
-
assert.
|
|
126
|
-
assert.strictEqual(status.duplicates.find((item) => item.path === canonicalImported).source_id, source.id)
|
|
294
|
+
assert.strictEqual(status.sources.some((item) => item.kind === 'external'), false)
|
|
127
295
|
assert.strictEqual(status.last_scan.home_bytes_total, content.length, 'Pinokio metric excludes external files')
|
|
128
|
-
assert.strictEqual(
|
|
296
|
+
assert.strictEqual(fs.existsSync(imported), true)
|
|
129
297
|
})
|
|
130
298
|
|
|
131
|
-
test('adding an external source
|
|
299
|
+
test('adding an external source persists only its canonical registry path', async () => {
|
|
132
300
|
const { home, vault } = await makeEnv()
|
|
133
301
|
const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-added-external-'))
|
|
134
302
|
homes.push(external)
|
|
@@ -140,14 +308,241 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
140
308
|
assert.strictEqual(added.source.kind, 'external')
|
|
141
309
|
assert.strictEqual(added.source.parent_id, 'external')
|
|
142
310
|
assert.strictEqual(vault.registry.scanIndex.size, 0, 'adding a source never scans it')
|
|
143
|
-
assert.
|
|
311
|
+
assert.deepStrictEqual(vault.registry.settings.external_sources, [await fs.promises.realpath(external)])
|
|
312
|
+
assert.strictEqual(fs.existsSync(path.resolve(home, 'vault', 'sources')), false)
|
|
313
|
+
assert.deepStrictEqual(await fs.promises.readdir(path.resolve(home, 'api')), [],
|
|
314
|
+
'adding a source never creates an app alias')
|
|
144
315
|
|
|
145
316
|
const repeated = await vault.addExternalSource(external)
|
|
146
|
-
assert.strictEqual(repeated.created, false, 'the same physical folder is not
|
|
317
|
+
assert.strictEqual(repeated.created, false, 'the same physical folder is not registered twice')
|
|
147
318
|
assert.strictEqual(vault.sources().filter((source) => source.kind === 'external').length, 1)
|
|
148
319
|
|
|
320
|
+
const reloaded = new Registry(path.resolve(home, 'vault'))
|
|
321
|
+
await reloaded.load()
|
|
322
|
+
assert.deepStrictEqual(reloaded.settings.external_sources, [await fs.promises.realpath(external)])
|
|
323
|
+
|
|
324
|
+
await vault.sweeper.scan()
|
|
325
|
+
const canonicalImported = path.resolve(await fs.promises.realpath(imported))
|
|
326
|
+
assert.ok(vault.registry.scanIndex.has(canonicalImported))
|
|
327
|
+
|
|
328
|
+
const removed = await vault.perform('remove_source', { source_id: added.source.id })
|
|
329
|
+
assert.strictEqual(removed.removed, true)
|
|
330
|
+
const status = await vault.status()
|
|
331
|
+
assert.strictEqual(vault.registry.links.has(canonicalImported), false)
|
|
332
|
+
assert.strictEqual(vault.registry.duplicates.has(canonicalImported), false)
|
|
333
|
+
assert.strictEqual(vault.registry.scanIndex.has(canonicalImported), false)
|
|
334
|
+
assert.strictEqual(status.sources.some((source) => source.kind === 'external'), false)
|
|
335
|
+
assert.strictEqual(status.duplicates.some((item) => item.path === canonicalImported), false)
|
|
336
|
+
assert.deepStrictEqual(vault.registry.settings.external_sources, [])
|
|
337
|
+
assert.deepStrictEqual(await fs.promises.readFile(imported), content)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test('deleting Vault state resets configured external sources without touching them', async () => {
|
|
341
|
+
const { home, kernel, vault } = await makeEnv()
|
|
342
|
+
const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-external-reset-'))
|
|
343
|
+
homes.push(external)
|
|
344
|
+
const externalFile = await writeFile(path.resolve(external, 'model.bin'), 'external data')
|
|
345
|
+
await vault.addExternalSource(external)
|
|
346
|
+
await fs.promises.rm(vault.root, { recursive: true, force: true })
|
|
347
|
+
|
|
348
|
+
const fresh = new Vault(kernel)
|
|
349
|
+
await fresh.init()
|
|
350
|
+
|
|
351
|
+
assert.deepStrictEqual(fresh.registry.settings.external_sources, [])
|
|
352
|
+
assert.strictEqual(fresh.sources().some((source) => source.kind === 'external'), false)
|
|
353
|
+
assert.strictEqual(await fs.promises.readFile(externalFile, 'utf8'), 'external data')
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
test('external sources cannot overlap', async () => {
|
|
357
|
+
const { vault } = await makeEnv()
|
|
358
|
+
const parent = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-external-parent-'))
|
|
359
|
+
const child = path.resolve(parent, 'child')
|
|
360
|
+
homes.push(parent)
|
|
361
|
+
await fs.promises.mkdir(child, { recursive: true })
|
|
362
|
+
await vault.addExternalSource(parent)
|
|
363
|
+
await assert.rejects(vault.addExternalSource(child), /does not contain or overlap/)
|
|
364
|
+
assert.strictEqual(vault.registry.settings.external_sources.length, 1)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('removing an external source is refused while one of its files is shared', async () => {
|
|
368
|
+
const { home, vault } = await makeEnv()
|
|
369
|
+
const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-external-shared-'))
|
|
370
|
+
homes.push(external)
|
|
371
|
+
const content = crypto.randomBytes(4096)
|
|
372
|
+
await writeFile(path.resolve(home, 'api', 'appA', 'model.bin'), content)
|
|
373
|
+
const externalFile = await writeFile(path.resolve(external, 'model.bin'), content)
|
|
374
|
+
const source = (await vault.addExternalSource(external)).source
|
|
375
|
+
|
|
376
|
+
await vault.sweeper.scan()
|
|
377
|
+
const converted = await vault.deduplicateScope(source.id)
|
|
378
|
+
assert.strictEqual(converted.converted, 1)
|
|
379
|
+
assert.strictEqual((await fs.promises.stat(externalFile)).nlink, 3)
|
|
380
|
+
|
|
381
|
+
await assert.rejects(
|
|
382
|
+
vault.removeExternalSource(source.id),
|
|
383
|
+
/Turn sharing off/
|
|
384
|
+
)
|
|
385
|
+
assert.deepStrictEqual(vault.registry.settings.external_sources, [await fs.promises.realpath(external)])
|
|
386
|
+
assert.strictEqual(fs.existsSync(externalFile), true)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
test('an unavailable external source must be reconnected before removal', async () => {
|
|
390
|
+
const { vault } = await makeEnv()
|
|
391
|
+
const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-external-offline-'))
|
|
392
|
+
const moved = `${external}-moved`
|
|
393
|
+
homes.push(moved)
|
|
394
|
+
const source = (await vault.addExternalSource(external)).source
|
|
395
|
+
await fs.promises.rename(external, moved)
|
|
396
|
+
|
|
397
|
+
await assert.rejects(
|
|
398
|
+
vault.removeExternalSource(source.id),
|
|
399
|
+
/Reconnect this folder/
|
|
400
|
+
)
|
|
401
|
+
assert.deepStrictEqual(vault.registry.settings.external_sources, [source.root])
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
test('an unavailable configured source makes a global scan incomplete without erasing it', async () => {
|
|
405
|
+
const { vault } = await makeEnv()
|
|
406
|
+
const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-external-scan-offline-'))
|
|
407
|
+
const moved = `${external}-moved`
|
|
408
|
+
homes.push(moved)
|
|
409
|
+
await writeFile(path.resolve(external, 'model.bin'), crypto.randomBytes(4096))
|
|
410
|
+
const source = (await vault.addExternalSource(external)).source
|
|
411
|
+
const externalFile = path.resolve(source.root, 'model.bin')
|
|
412
|
+
await vault.sweeper.scan()
|
|
413
|
+
const beforeScan = JSON.parse(JSON.stringify(vault.registry.lastScan))
|
|
414
|
+
assert.strictEqual(vault.registry.links.has(externalFile), true)
|
|
415
|
+
await fs.promises.rename(external, moved)
|
|
416
|
+
|
|
417
|
+
const result = await vault.sweeper.scan()
|
|
418
|
+
|
|
419
|
+
assert.strictEqual(result.incomplete, true)
|
|
420
|
+
assert.deepStrictEqual(vault.registry.lastScan, beforeScan)
|
|
421
|
+
assert.strictEqual(vault.registry.links.has(externalFile), true)
|
|
422
|
+
assert.deepStrictEqual(vault.sweeper.state.inaccessible_paths, [source.root])
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
test('a transient source-enumeration error preserves the last complete source map', async () => {
|
|
426
|
+
const { home, vault } = await makeEnv()
|
|
427
|
+
await fs.promises.mkdir(path.resolve(home, 'api', 'appA'), { recursive: true })
|
|
428
|
+
await vault.refreshSources()
|
|
429
|
+
const before = vault.sources().map((source) => source.id)
|
|
430
|
+
const apiRoot = path.resolve(home, 'api')
|
|
431
|
+
const realReaddir = fs.promises.readdir
|
|
432
|
+
fs.promises.readdir = async (target, options) => {
|
|
433
|
+
if (path.resolve(target) === apiRoot) {
|
|
434
|
+
const error = new Error('transient read failure')
|
|
435
|
+
error.code = 'EIO'
|
|
436
|
+
throw error
|
|
437
|
+
}
|
|
438
|
+
return realReaddir(target, options)
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
await assert.rejects(vault.refreshSources(), (error) => error.code === 'EIO')
|
|
442
|
+
} finally {
|
|
443
|
+
fs.promises.readdir = realReaddir
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
assert.deepStrictEqual(vault.sources().map((source) => source.id), before)
|
|
447
|
+
assert.ok(vault.sources().some((source) => source.kind === 'app' && source.app === 'appA'))
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
test('a transient external-source metadata error fails the scan without publishing an incomplete result', async () => {
|
|
451
|
+
const { vault } = await makeEnv()
|
|
452
|
+
const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-external-metadata-'))
|
|
453
|
+
homes.push(external)
|
|
454
|
+
await writeFile(path.resolve(external, 'model.bin'), crypto.randomBytes(4096))
|
|
455
|
+
await vault.addExternalSource(external)
|
|
456
|
+
await vault.sweeper.scan()
|
|
457
|
+
const beforeScan = JSON.parse(JSON.stringify(vault.registry.lastScan))
|
|
458
|
+
const beforeSources = vault.sources().map((source) => source.id)
|
|
459
|
+
const canonicalExternal = await fs.promises.realpath(external)
|
|
460
|
+
const realLstat = fs.promises.lstat
|
|
461
|
+
let externalStats = 0
|
|
462
|
+
fs.promises.lstat = async (target, options) => {
|
|
463
|
+
if (path.resolve(String(target)) === path.resolve(canonicalExternal) && ++externalStats === 1) {
|
|
464
|
+
const error = new Error('temporary external metadata failure')
|
|
465
|
+
error.code = 'EIO'
|
|
466
|
+
throw error
|
|
467
|
+
}
|
|
468
|
+
return realLstat(target, options)
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
await assert.rejects(vault.sweeper.scan(), (error) => error && error.code === 'EIO')
|
|
472
|
+
} finally {
|
|
473
|
+
fs.promises.lstat = realLstat
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
assert.deepStrictEqual(vault.registry.lastScan, beforeScan)
|
|
477
|
+
assert.deepStrictEqual(vault.sources().map((source) => source.id), beforeSources)
|
|
478
|
+
assert.strictEqual(vault.sweeper.state.phase, 'failed')
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
test('an access-denied subtree is skipped without erasing its records or exact scan totals', async () => {
|
|
482
|
+
const { home, vault } = await makeEnv()
|
|
483
|
+
const deniedDir = path.resolve(home, 'api', 'appA', 'private')
|
|
484
|
+
const deniedFile = await writeFile(path.resolve(deniedDir, 'model.bin'), crypto.randomBytes(4096))
|
|
149
485
|
await vault.sweeper.scan()
|
|
150
|
-
|
|
486
|
+
const beforeScan = JSON.parse(JSON.stringify(vault.registry.lastScan))
|
|
487
|
+
assert.strictEqual(vault.registry.links.has(deniedFile), true)
|
|
488
|
+
|
|
489
|
+
const accessibleFile = await writeFile(
|
|
490
|
+
path.resolve(home, 'api', 'appB', 'model.bin'),
|
|
491
|
+
crypto.randomBytes(4096)
|
|
492
|
+
)
|
|
493
|
+
const realLstat = fs.promises.lstat
|
|
494
|
+
fs.promises.lstat = async (target, options) => {
|
|
495
|
+
if (path.resolve(String(target)) === deniedDir) {
|
|
496
|
+
const error = new Error('permission denied')
|
|
497
|
+
error.code = 'EACCES'
|
|
498
|
+
throw error
|
|
499
|
+
}
|
|
500
|
+
return realLstat(target, options)
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
const result = await vault.sweeper.scan()
|
|
504
|
+
assert.strictEqual(result.incomplete, true)
|
|
505
|
+
} finally {
|
|
506
|
+
fs.promises.lstat = realLstat
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
assert.strictEqual(vault.sweeper.state.phase, 'incomplete')
|
|
510
|
+
assert.strictEqual(vault.sweeper.state.inaccessible, 1)
|
|
511
|
+
assert.deepStrictEqual(vault.sweeper.state.inaccessible_paths, [deniedDir])
|
|
512
|
+
assert.deepStrictEqual(vault.registry.lastScan, beforeScan, 'partial counters never replace exact totals')
|
|
513
|
+
assert.strictEqual(vault.registry.links.has(deniedFile), true, 'prior record under denied subtree survives')
|
|
514
|
+
assert.strictEqual(vault.registry.links.has(accessibleFile), true, 'accessible paths are still scanned')
|
|
515
|
+
|
|
516
|
+
await vault.sweeper.scan()
|
|
517
|
+
assert.strictEqual(vault.sweeper.state.phase, 'complete')
|
|
518
|
+
assert.strictEqual(vault.sweeper.state.inaccessible, 0)
|
|
519
|
+
assert.strictEqual(vault.registry.lastScan.files, 2)
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
test('a failed registry write does not retain a new external source', async () => {
|
|
523
|
+
const { vault } = await makeEnv()
|
|
524
|
+
const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-add-rollback-'))
|
|
525
|
+
homes.push(external)
|
|
526
|
+
const realAtomicWrite = vault.registry.atomicWrite.bind(vault.registry)
|
|
527
|
+
let failed = false
|
|
528
|
+
vault.registry.atomicWrite = async (...args) => {
|
|
529
|
+
if (!failed) {
|
|
530
|
+
failed = true
|
|
531
|
+
const error = new Error('transient write failure')
|
|
532
|
+
error.code = 'EIO'
|
|
533
|
+
throw error
|
|
534
|
+
}
|
|
535
|
+
return realAtomicWrite(...args)
|
|
536
|
+
}
|
|
537
|
+
try {
|
|
538
|
+
await assert.rejects(vault.addExternalSource(external), (error) => error.code === 'EIO')
|
|
539
|
+
} finally {
|
|
540
|
+
vault.registry.atomicWrite = realAtomicWrite
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
assert.deepStrictEqual(vault.registry.settings.external_sources, [])
|
|
544
|
+
assert.strictEqual(vault.sources().some((source) => source.kind === 'external'), false)
|
|
545
|
+
assert.strictEqual(fs.existsSync(path.resolve(vault.root, 'sources')), false)
|
|
151
546
|
})
|
|
152
547
|
|
|
153
548
|
test('excluded paths are respected: no adoption, no pending, no re-listing', async () => {
|
|
@@ -166,11 +561,202 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
166
561
|
const { home, vault } = await makeEnv()
|
|
167
562
|
await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), crypto.randomBytes(4096))
|
|
168
563
|
await vault.sweeper.scan()
|
|
169
|
-
const after = vault.sweeper.stats.hashes
|
|
170
564
|
const priorFiles = vault.registry.lastScan.files
|
|
171
565
|
await vault.sweeper.scan()
|
|
172
|
-
assert.strictEqual(vault.
|
|
173
|
-
assert.strictEqual(vault.sweeper.state.
|
|
566
|
+
assert.strictEqual(vault.registry.lastScan.hashed, 0)
|
|
567
|
+
assert.strictEqual(vault.sweeper.state.total_files, priorFiles, 'every scan uses a fresh exact file count')
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
test('a completed scan refreshes dead names and orphan state', async () => {
|
|
571
|
+
const { home, vault } = await makeEnv()
|
|
572
|
+
const content = crypto.randomBytes(4096)
|
|
573
|
+
const hash = sha256(content)
|
|
574
|
+
const file = await writeFile(path.resolve(home, 'api', 'appA', 'model.bin'), content)
|
|
575
|
+
await vault.adopt(file, hash, { app: 'appA' })
|
|
576
|
+
await fs.promises.unlink(file)
|
|
577
|
+
|
|
578
|
+
await vault.sweeper.scan()
|
|
579
|
+
|
|
580
|
+
assert.strictEqual(vault.registry.links.has(file), false)
|
|
581
|
+
assert.strictEqual(vault.registry.blobs.get(hash).orphan, true)
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
test('scan-derived state is persisted once at the end of a scan', async () => {
|
|
585
|
+
const { home, vault } = await makeEnv()
|
|
586
|
+
await Promise.all(Array.from({ length: 8 }, (_, index) =>
|
|
587
|
+
writeFile(path.resolve(home, 'api', 'appA', `model-${index}.bin`), crypto.randomBytes(4096))))
|
|
588
|
+
const realFlush = vault.registry.flush.bind(vault.registry)
|
|
589
|
+
let flushes = 0
|
|
590
|
+
vault.registry.flush = async (...args) => {
|
|
591
|
+
flushes += 1
|
|
592
|
+
return realFlush(...args)
|
|
593
|
+
}
|
|
594
|
+
vault.registry.schedulePersist()
|
|
595
|
+
|
|
596
|
+
await vault.sweeper.scan()
|
|
597
|
+
|
|
598
|
+
assert.strictEqual(flushes, 1)
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
test('a completed scan reports a persistence warning without becoming failed', async () => {
|
|
602
|
+
const { home, vault } = await makeEnv()
|
|
603
|
+
await writeFile(path.resolve(home, 'api', 'appA', 'model.bin'), crypto.randomBytes(4096))
|
|
604
|
+
const realAtomicWrite = vault.registry.atomicWrite.bind(vault.registry)
|
|
605
|
+
vault.registry.persistDelay = 60000
|
|
606
|
+
vault.registry.atomicWrite = async () => {
|
|
607
|
+
const error = new Error('temporary snapshot write failure')
|
|
608
|
+
error.code = 'EIO'
|
|
609
|
+
throw error
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
assert.deepStrictEqual(vault.startScan(), { started: true })
|
|
614
|
+
await vault.scanPromise
|
|
615
|
+
const scan = vault.scanStatus()
|
|
616
|
+
assert.strictEqual(scan.phase, 'complete')
|
|
617
|
+
assert.strictEqual(scan.error, null)
|
|
618
|
+
assert.strictEqual(scan.persistence_warning, true)
|
|
619
|
+
assert.ok(vault.registry.lastScan && vault.registry.lastScan.files === 1)
|
|
620
|
+
assert.strictEqual(vault.registry.persistDirty, true)
|
|
621
|
+
} finally {
|
|
622
|
+
vault.registry.atomicWrite = realAtomicWrite
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
await vault.registry.flush()
|
|
626
|
+
assert.strictEqual(vault.scanStatus().persistence_warning, false)
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
test('a first scan counts the exact file total before processing begins', async () => {
|
|
630
|
+
const { home, vault } = await makeEnv()
|
|
631
|
+
const dir = path.resolve(home, 'api', 'appA')
|
|
632
|
+
await Promise.all(Array.from({ length: 600 }, (_, index) =>
|
|
633
|
+
writeFile(path.resolve(dir, `small-${index}.txt`), 'x')))
|
|
634
|
+
|
|
635
|
+
const sweeper = vault.sweeper
|
|
636
|
+
const realWalk = sweeper.walk.bind(sweeper)
|
|
637
|
+
let beforeWalk = null
|
|
638
|
+
sweeper.walk = async (...args) => {
|
|
639
|
+
beforeWalk = {
|
|
640
|
+
phase: sweeper.state.phase,
|
|
641
|
+
total: sweeper.state.total_files,
|
|
642
|
+
counted: sweeper.state.counted_files
|
|
643
|
+
}
|
|
644
|
+
return realWalk(...args)
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
await sweeper.scan()
|
|
648
|
+
} finally {
|
|
649
|
+
sweeper.walk = realWalk
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
assert.deepStrictEqual(beforeWalk, { phase: 'discovering', total: 600, counted: 600 })
|
|
653
|
+
assert.strictEqual(sweeper.state.files, 600)
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
test('a changed tree receives a new exact progress total', async () => {
|
|
657
|
+
const { home, vault } = await makeEnv()
|
|
658
|
+
const dir = path.resolve(home, 'api', 'appA')
|
|
659
|
+
await writeFile(path.resolve(dir, 'first.txt'), 'x')
|
|
660
|
+
await vault.sweeper.scan()
|
|
661
|
+
await writeFile(path.resolve(dir, 'second.txt'), 'x')
|
|
662
|
+
|
|
663
|
+
await vault.sweeper.scan()
|
|
664
|
+
|
|
665
|
+
assert.strictEqual(vault.sweeper.state.files, 2)
|
|
666
|
+
assert.strictEqual(vault.sweeper.state.total_files, 2)
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
test('a stale previous scan count is never reused as the current exact progress total', async () => {
|
|
670
|
+
const { home, vault } = await makeEnv()
|
|
671
|
+
await writeFile(path.resolve(home, 'api', 'appA', 'first.txt'), 'x')
|
|
672
|
+
vault.registry.lastScan = {
|
|
673
|
+
files: 100,
|
|
674
|
+
duration_ms: 1000,
|
|
675
|
+
walk_duration_ms: 800
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
await vault.sweeper.scan()
|
|
679
|
+
|
|
680
|
+
assert.strictEqual(vault.sweeper.state.files, 1)
|
|
681
|
+
assert.strictEqual(vault.sweeper.state.total_files, 1)
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
test('a rescan exposes its previous exact count as an approximate counting baseline', async () => {
|
|
685
|
+
const { home, vault } = await makeEnv()
|
|
686
|
+
await writeFile(path.resolve(home, 'api', 'appA', 'first.txt'), 'x')
|
|
687
|
+
vault.registry.lastScan = {
|
|
688
|
+
files: 100,
|
|
689
|
+
duration_ms: 1000,
|
|
690
|
+
count_duration_ms: 300,
|
|
691
|
+
walk_duration_ms: 600,
|
|
692
|
+
hash_wait_duration_ms: 100
|
|
693
|
+
}
|
|
694
|
+
const sweeper = vault.sweeper
|
|
695
|
+
const realCount = sweeper.countFiles.bind(sweeper)
|
|
696
|
+
let inspect
|
|
697
|
+
sweeper.countFiles = async (...args) => {
|
|
698
|
+
inspect = {
|
|
699
|
+
estimate: sweeper.state.count_estimate_files,
|
|
700
|
+
countWeight: sweeper.state.estimated_count_weight,
|
|
701
|
+
walkWeight: sweeper.state.estimated_walk_weight
|
|
702
|
+
}
|
|
703
|
+
return realCount(...args)
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
await sweeper.scan()
|
|
707
|
+
} finally {
|
|
708
|
+
sweeper.countFiles = realCount
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
assert.deepStrictEqual(inspect, { estimate: 100, countWeight: 0.3, walkWeight: 0.6 })
|
|
712
|
+
assert.strictEqual(sweeper.state.total_files, 1, 'the completed current count remains exact')
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
test('a transient metadata failure fails the scan without replacing its last complete result', async () => {
|
|
716
|
+
const { home, vault } = await makeEnv()
|
|
717
|
+
await writeFile(path.resolve(home, 'api', 'appA', 'first.txt'), 'x')
|
|
718
|
+
await vault.sweeper.scan()
|
|
719
|
+
const before = JSON.parse(JSON.stringify(vault.registry.lastScan))
|
|
720
|
+
const unreadable = await writeFile(path.resolve(home, 'api', 'appA', 'second.txt'), 'x')
|
|
721
|
+
const realLstat = fs.promises.lstat
|
|
722
|
+
fs.promises.lstat = async (target, options) => {
|
|
723
|
+
if (path.resolve(String(target)) === unreadable) {
|
|
724
|
+
const error = new Error('temporary metadata failure')
|
|
725
|
+
error.code = 'EIO'
|
|
726
|
+
throw error
|
|
727
|
+
}
|
|
728
|
+
return realLstat(target, options)
|
|
729
|
+
}
|
|
730
|
+
try {
|
|
731
|
+
await assert.rejects(vault.sweeper.scan(), (error) => error && error.code === 'EIO')
|
|
732
|
+
} finally {
|
|
733
|
+
fs.promises.lstat = realLstat
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
assert.deepStrictEqual(vault.registry.lastScan, before)
|
|
737
|
+
assert.strictEqual(vault.sweeper.state.active, false)
|
|
738
|
+
assert.strictEqual(vault.sweeper.state.phase, 'failed')
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
test('a verification failure does not publish a completed scan timestamp', async () => {
|
|
742
|
+
const { home, vault } = await makeEnv()
|
|
743
|
+
await writeFile(path.resolve(home, 'api', 'appA', 'first.txt'), 'x')
|
|
744
|
+
await vault.sweeper.scan()
|
|
745
|
+
const before = JSON.parse(JSON.stringify(vault.registry.lastScan))
|
|
746
|
+
const realVerify = vault.verify.bind(vault)
|
|
747
|
+
vault.verify = async () => {
|
|
748
|
+
const error = new Error('temporary verification failure')
|
|
749
|
+
error.code = 'EIO'
|
|
750
|
+
throw error
|
|
751
|
+
}
|
|
752
|
+
try {
|
|
753
|
+
await assert.rejects(vault.sweeper.scan(), (error) => error && error.code === 'EIO')
|
|
754
|
+
} finally {
|
|
755
|
+
vault.verify = realVerify
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
assert.deepStrictEqual(vault.registry.lastScan, before)
|
|
759
|
+
assert.strictEqual(vault.sweeper.state.phase, 'failed')
|
|
174
760
|
})
|
|
175
761
|
|
|
176
762
|
test('file metadata inspection uses bounded concurrency without changing results', async () => {
|
|
@@ -178,32 +764,55 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
178
764
|
const dir = path.resolve(home, 'api', 'bulk')
|
|
179
765
|
await Promise.all(Array.from({ length: 48 }, (_, index) =>
|
|
180
766
|
writeFile(path.resolve(dir, `dir-${index}`, 'small.txt'), 'x')))
|
|
181
|
-
const
|
|
767
|
+
const realLstat = fs.promises.lstat
|
|
182
768
|
let active = 0
|
|
183
769
|
let maximum = 0
|
|
184
|
-
fs.promises.
|
|
770
|
+
fs.promises.lstat = async (target) => {
|
|
185
771
|
if (String(target).startsWith(dir + path.sep)) {
|
|
186
772
|
active += 1
|
|
187
773
|
maximum = Math.max(maximum, active)
|
|
188
774
|
await new Promise((resolve) => setTimeout(resolve, 3))
|
|
189
775
|
try {
|
|
190
|
-
return await
|
|
776
|
+
return await realLstat(target)
|
|
191
777
|
} finally {
|
|
192
778
|
active -= 1
|
|
193
779
|
}
|
|
194
780
|
}
|
|
195
|
-
return
|
|
781
|
+
return realLstat(target)
|
|
196
782
|
}
|
|
197
783
|
try {
|
|
198
784
|
await vault.sweeper.scan()
|
|
199
785
|
} finally {
|
|
200
|
-
fs.promises.
|
|
786
|
+
fs.promises.lstat = realLstat
|
|
201
787
|
}
|
|
202
788
|
assert.ok(maximum > 1, `expected concurrent metadata reads, saw ${maximum}`)
|
|
203
789
|
assert.ok(maximum <= vault.statConcurrency, `metadata concurrency exceeded its bound: ${maximum}`)
|
|
204
790
|
assert.strictEqual(vault.registry.lastScan.files >= 48, true)
|
|
205
791
|
})
|
|
206
792
|
|
|
793
|
+
test('content hashing applies backpressure to keep the pending queue bounded', async () => {
|
|
794
|
+
const { home, vault } = await makeEnv()
|
|
795
|
+
await Promise.all(Array.from({ length: 8 }, (_, index) =>
|
|
796
|
+
writeFile(path.resolve(home, 'api', 'appA', `model-${index}.bin`), crypto.randomBytes(4096))))
|
|
797
|
+
vault.sweeper.hashQueueLimit = 2
|
|
798
|
+
const realHashFile = vault.hashFile.bind(vault)
|
|
799
|
+
let maximumQueued = 0
|
|
800
|
+
vault.hashFile = async (...args) => {
|
|
801
|
+
maximumQueued = Math.max(maximumQueued, vault.sweeper.state.queued)
|
|
802
|
+
await new Promise((resolve) => setTimeout(resolve, 3))
|
|
803
|
+
return realHashFile(...args)
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
try {
|
|
807
|
+
await vault.sweeper.scan()
|
|
808
|
+
} finally {
|
|
809
|
+
vault.hashFile = realHashFile
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
assert.ok(maximumQueued <= 2, `hash queue exceeded its bound: ${maximumQueued}`)
|
|
813
|
+
assert.strictEqual(vault.registry.lastScan.hashed, 8)
|
|
814
|
+
})
|
|
815
|
+
|
|
207
816
|
test('multiple names for one inode share one exact hash job', async () => {
|
|
208
817
|
const { home, vault } = await makeEnv()
|
|
209
818
|
const content = crypto.randomBytes(4096)
|
|
@@ -221,6 +830,34 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
221
830
|
assert.strictEqual(vault.registry.links.get(b).hash, sha256(content))
|
|
222
831
|
})
|
|
223
832
|
|
|
833
|
+
test('an inode discovered after its hash job completes reuses the completed digest', async () => {
|
|
834
|
+
const { home, vault } = await makeEnv()
|
|
835
|
+
const content = crypto.randomBytes(4096)
|
|
836
|
+
const a = await writeFile(path.resolve(home, 'api', 'appA', 'model.bin'), content)
|
|
837
|
+
const b = path.resolve(home, 'api', 'appB', 'model.bin')
|
|
838
|
+
await fs.promises.mkdir(path.dirname(b), { recursive: true })
|
|
839
|
+
await fs.promises.link(a, b)
|
|
840
|
+
const realConsiderStat = vault.sweeper.considerStat.bind(vault.sweeper)
|
|
841
|
+
let candidates = 0
|
|
842
|
+
vault.sweeper.considerStat = async (...args) => {
|
|
843
|
+
const result = await realConsiderStat(...args)
|
|
844
|
+
candidates += 1
|
|
845
|
+
if (candidates === 1) await vault.sweeper.settle()
|
|
846
|
+
return result
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
try {
|
|
850
|
+
await vault.sweeper.scan()
|
|
851
|
+
} finally {
|
|
852
|
+
vault.sweeper.considerStat = realConsiderStat
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
assert.strictEqual(vault.registry.lastScan.hashed, 1)
|
|
856
|
+
assert.strictEqual(vault.registry.lastScan.inode_reuses, 1)
|
|
857
|
+
assert.strictEqual(vault.registry.links.get(a).hash, sha256(content))
|
|
858
|
+
assert.strictEqual(vault.registry.links.get(b).hash, sha256(content))
|
|
859
|
+
})
|
|
860
|
+
|
|
224
861
|
test('a file changed during hashing never publishes the stale digest', async () => {
|
|
225
862
|
const { home, vault } = await makeEnv()
|
|
226
863
|
const original = crypto.randomBytes(4096)
|
|
@@ -247,6 +884,28 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
247
884
|
assert.strictEqual((await fs.promises.stat(file)).nlink, 1, 'unstable file was not adopted')
|
|
248
885
|
})
|
|
249
886
|
|
|
887
|
+
test('a file read failure aborts without publishing partial state', async () => {
|
|
888
|
+
const { home, vault } = await makeEnv()
|
|
889
|
+
const file = await writeFile(path.resolve(home, 'api', 'appA', 'unreadable.bin'), crypto.randomBytes(4096))
|
|
890
|
+
const realHashFile = vault.hashFile.bind(vault)
|
|
891
|
+
vault.hashFile = async () => {
|
|
892
|
+
const error = new Error('temporary read failure')
|
|
893
|
+
error.code = 'EIO'
|
|
894
|
+
throw error
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const before = vault.registry.snapshotState()
|
|
898
|
+
try {
|
|
899
|
+
await assert.rejects(vault.sweeper.scan(), (error) => error && error.code === 'EIO')
|
|
900
|
+
} finally {
|
|
901
|
+
vault.hashFile = realHashFile
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
assert.strictEqual(vault.registry.lastScan, before.lastScan)
|
|
905
|
+
assert.strictEqual(vault.registry.scanIndex.has(file), false)
|
|
906
|
+
assert.strictEqual(vault.sweeper.state.phase, 'failed')
|
|
907
|
+
})
|
|
908
|
+
|
|
250
909
|
test('conversion tmp files are never candidates', async () => {
|
|
251
910
|
const { home, vault } = await makeEnv()
|
|
252
911
|
const stray = await writeFile(path.resolve(home, 'api', 'appA', 'm.bin.pinokio-dedup-tmp'), crypto.randomBytes(4096))
|
|
@@ -266,11 +925,262 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
266
925
|
path.resolve(dir, '.cache', 'huggingface', 'model.safetensors.metadata'),
|
|
267
926
|
`commit123\n"${hash}"\n${future}\n`)
|
|
268
927
|
await vault.sweeper.scan()
|
|
269
|
-
assert.strictEqual(vault.
|
|
928
|
+
assert.strictEqual(vault.registry.lastScan.hashed, 0, 'hash harvested, not computed')
|
|
270
929
|
assert.strictEqual((await fs.promises.stat(file)).nlink, 2)
|
|
271
930
|
assert.ok(vault.registry.blobs.has(hash))
|
|
272
931
|
})
|
|
273
932
|
|
|
933
|
+
test('hf --local-dir metadata older than the file always falls back to content hashing', async () => {
|
|
934
|
+
const { home, vault } = await makeEnv()
|
|
935
|
+
const content = crypto.randomBytes(4096)
|
|
936
|
+
const hash = sha256(content)
|
|
937
|
+
const staleHash = hash === 'a'.repeat(64) ? 'b'.repeat(64) : 'a'.repeat(64)
|
|
938
|
+
const dir = path.resolve(home, 'api', 'appA', 'ckpt')
|
|
939
|
+
const file = await writeFile(path.resolve(dir, 'model.safetensors'), content)
|
|
940
|
+
const fileStat = await fs.promises.stat(file)
|
|
941
|
+
const staleTimestamp = (fileStat.mtimeMs - 500) / 1000
|
|
942
|
+
await writeFile(
|
|
943
|
+
path.resolve(dir, '.cache', 'huggingface', 'model.safetensors.metadata'),
|
|
944
|
+
`commit123\n"${staleHash}"\n${staleTimestamp}\n`
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
await vault.sweeper.scan()
|
|
948
|
+
|
|
949
|
+
assert.strictEqual(vault.registry.lastScan.hashed, 1)
|
|
950
|
+
assert.ok(vault.registry.blobs.has(hash))
|
|
951
|
+
assert.strictEqual(vault.registry.blobs.has(staleHash), false)
|
|
952
|
+
})
|
|
953
|
+
|
|
954
|
+
test('linked hf metadata is never followed for hash-free classification', {
|
|
955
|
+
skip: process.platform === 'win32'
|
|
956
|
+
}, async () => {
|
|
957
|
+
const { home, vault } = await makeEnv()
|
|
958
|
+
const content = crypto.randomBytes(4096)
|
|
959
|
+
const actualHash = sha256(content)
|
|
960
|
+
const forgedHash = actualHash === 'a'.repeat(64) ? 'b'.repeat(64) : 'a'.repeat(64)
|
|
961
|
+
const dir = path.resolve(home, 'api', 'appA', 'ckpt')
|
|
962
|
+
await writeFile(path.resolve(dir, 'model.safetensors'), content)
|
|
963
|
+
const outside = await writeFile(path.resolve(home, 'forged.metadata'),
|
|
964
|
+
`commit123\n"${forgedHash}"\n${(Date.now() + 60000) / 1000}\n`)
|
|
965
|
+
const metadata = path.resolve(dir, '.cache', 'huggingface', 'model.safetensors.metadata')
|
|
966
|
+
await fs.promises.mkdir(path.dirname(metadata), { recursive: true })
|
|
967
|
+
await fs.promises.symlink(outside, metadata)
|
|
968
|
+
|
|
969
|
+
await vault.sweeper.scan()
|
|
970
|
+
|
|
971
|
+
assert.strictEqual(vault.registry.lastScan.hashed, 1)
|
|
972
|
+
assert.strictEqual(vault.registry.blobs.has(actualHash), true)
|
|
973
|
+
assert.strictEqual(vault.registry.blobs.has(forgedHash), false)
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
test('hash-free metadata outside an external source cannot classify its files', async () => {
|
|
977
|
+
const { vault } = await makeEnv()
|
|
978
|
+
const parent = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-metadata-scope-'))
|
|
979
|
+
homes.push(parent)
|
|
980
|
+
const external = path.resolve(parent, 'external')
|
|
981
|
+
const content = crypto.randomBytes(4096)
|
|
982
|
+
const actualHash = sha256(content)
|
|
983
|
+
const forgedHash = actualHash === 'a'.repeat(64) ? 'b'.repeat(64) : 'a'.repeat(64)
|
|
984
|
+
const file = await writeFile(path.resolve(external, 'model.bin'), content)
|
|
985
|
+
await writeFile(
|
|
986
|
+
path.resolve(parent, '.cache', 'huggingface', 'external', 'model.bin.metadata'),
|
|
987
|
+
`commit123\n"${forgedHash}"\n${(Date.now() + 60000) / 1000}\n`
|
|
988
|
+
)
|
|
989
|
+
await vault.addExternalSource(external)
|
|
990
|
+
const canonicalFile = path.resolve(await fs.promises.realpath(file))
|
|
991
|
+
|
|
992
|
+
await vault.sweeper.scan()
|
|
993
|
+
|
|
994
|
+
assert.strictEqual(vault.registry.scanIndex.get(canonicalFile).hash, actualHash)
|
|
995
|
+
assert.strictEqual(vault.registry.blobs.has(actualHash), true)
|
|
996
|
+
assert.strictEqual(vault.registry.blobs.has(forgedHash), false)
|
|
997
|
+
})
|
|
998
|
+
|
|
999
|
+
test('a changed pending path transitions to exactly one new classification', async () => {
|
|
1000
|
+
const { home, vault } = await makeEnv()
|
|
1001
|
+
const original = Buffer.alloc(4096, 1)
|
|
1002
|
+
const changed = Buffer.alloc(4096, 2)
|
|
1003
|
+
await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), original)
|
|
1004
|
+
await writeFile(path.resolve(home, 'api', 'appB', 'm.bin'), original)
|
|
1005
|
+
await vault.sweeper.scan()
|
|
1006
|
+
const pending = [...vault.registry.duplicates.keys()][0]
|
|
1007
|
+
|
|
1008
|
+
await fs.promises.writeFile(pending, changed)
|
|
1009
|
+
const future = new Date(Date.now() + 2000)
|
|
1010
|
+
await fs.promises.utimes(pending, future, future)
|
|
1011
|
+
await vault.sweeper.scan()
|
|
1012
|
+
|
|
1013
|
+
assert.strictEqual(vault.registry.duplicates.has(pending), false)
|
|
1014
|
+
assert.strictEqual(vault.registry.links.get(pending).hash, sha256(changed))
|
|
1015
|
+
assert.strictEqual(vault.registry.scanIndex.get(pending).hash, sha256(changed))
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
test('a changed pending path updates to a different existing content group', async () => {
|
|
1019
|
+
const { home, vault } = await makeEnv()
|
|
1020
|
+
const first = Buffer.alloc(4096, 3)
|
|
1021
|
+
const second = Buffer.alloc(4096, 4)
|
|
1022
|
+
await writeFile(path.resolve(home, 'api', 'appA', 'a.bin'), first)
|
|
1023
|
+
await writeFile(path.resolve(home, 'api', 'appB', 'a.bin'), first)
|
|
1024
|
+
await writeFile(path.resolve(home, 'api', 'appC', 'b.bin'), second)
|
|
1025
|
+
await vault.sweeper.scan()
|
|
1026
|
+
const pending = [...vault.registry.duplicates.keys()][0]
|
|
1027
|
+
|
|
1028
|
+
await fs.promises.writeFile(pending, second)
|
|
1029
|
+
const future = new Date(Date.now() + 2000)
|
|
1030
|
+
await fs.promises.utimes(pending, future, future)
|
|
1031
|
+
await vault.sweeper.scan()
|
|
1032
|
+
|
|
1033
|
+
assert.strictEqual(vault.registry.links.has(pending), false)
|
|
1034
|
+
assert.strictEqual(vault.registry.duplicates.get(pending).hash, sha256(second))
|
|
1035
|
+
assert.strictEqual(vault.registry.scanIndex.get(pending).hash, sha256(second))
|
|
1036
|
+
})
|
|
1037
|
+
|
|
1038
|
+
test('files below the threshold and deleted files leave no derived scan state', async () => {
|
|
1039
|
+
const { home, vault } = await makeEnv()
|
|
1040
|
+
const content = Buffer.alloc(4096, 5)
|
|
1041
|
+
await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), content)
|
|
1042
|
+
await writeFile(path.resolve(home, 'api', 'appB', 'm.bin'), content)
|
|
1043
|
+
await vault.sweeper.scan()
|
|
1044
|
+
const pending = [...vault.registry.duplicates.keys()][0]
|
|
1045
|
+
const linked = [...vault.registry.links.keys()].find((filePath) => filePath !== pending)
|
|
1046
|
+
|
|
1047
|
+
await fs.promises.writeFile(pending, Buffer.alloc(128, 6))
|
|
1048
|
+
await fs.promises.unlink(linked)
|
|
1049
|
+
await vault.sweeper.scan()
|
|
1050
|
+
|
|
1051
|
+
assert.strictEqual(vault.registry.duplicates.has(pending), false)
|
|
1052
|
+
assert.strictEqual(vault.registry.scanIndex.has(pending), false)
|
|
1053
|
+
assert.strictEqual(vault.registry.links.has(linked), false)
|
|
1054
|
+
assert.strictEqual(vault.registry.scanIndex.has(linked), false)
|
|
1055
|
+
})
|
|
1056
|
+
|
|
1057
|
+
test('raising the candidate minimum drops pending suggestions but preserves unchanged shared files', async () => {
|
|
1058
|
+
const { home, vault } = await makeEnv()
|
|
1059
|
+
const content = Buffer.alloc(4096, 5)
|
|
1060
|
+
await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), content)
|
|
1061
|
+
await writeFile(path.resolve(home, 'api', 'appB', 'm.bin'), content)
|
|
1062
|
+
await vault.sweeper.scan()
|
|
1063
|
+
const pending = [...vault.registry.duplicates.keys()][0]
|
|
1064
|
+
const linked = [...vault.registry.links.keys()].find((filePath) => filePath !== pending)
|
|
1065
|
+
|
|
1066
|
+
vault.sizeThreshold = 8192
|
|
1067
|
+
await vault.sweeper.scan()
|
|
1068
|
+
|
|
1069
|
+
assert.strictEqual(vault.registry.duplicates.has(pending), false)
|
|
1070
|
+
assert.strictEqual(vault.registry.scanIndex.has(pending), false)
|
|
1071
|
+
assert.strictEqual(vault.registry.links.has(linked), true)
|
|
1072
|
+
assert.strictEqual(vault.registry.scanIndex.has(linked), true)
|
|
1073
|
+
|
|
1074
|
+
await fs.promises.writeFile(linked, Buffer.alloc(128, 6))
|
|
1075
|
+
await vault.sweeper.scan()
|
|
1076
|
+
assert.strictEqual(vault.registry.links.get(linked).hash, sha256(Buffer.alloc(128, 6)),
|
|
1077
|
+
'a file that actually changed below the minimum is still reconciled')
|
|
1078
|
+
})
|
|
1079
|
+
|
|
1080
|
+
test('an explicit scan persists and records a validated candidate minimum', async () => {
|
|
1081
|
+
const { home, vault } = await makeEnv()
|
|
1082
|
+
const content = Buffer.alloc(10 * 1000 * 1000, 7)
|
|
1083
|
+
await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), content)
|
|
1084
|
+
await writeFile(path.resolve(home, 'api', 'appB', 'm.bin'), content)
|
|
1085
|
+
|
|
1086
|
+
const invalid = await vault.perform('scan', { candidate_min_bytes: 1234 })
|
|
1087
|
+
assert.match(invalid.error, /available minimum file sizes/)
|
|
1088
|
+
assert.strictEqual(vault.scanPromise, undefined)
|
|
1089
|
+
|
|
1090
|
+
const started = await vault.perform('scan', { candidate_min_bytes: 10 * 1000 * 1000 })
|
|
1091
|
+
assert.strictEqual(started.started, true)
|
|
1092
|
+
await vault.scanPromise
|
|
1093
|
+
|
|
1094
|
+
assert.strictEqual(vault.registry.settings.candidate_min_bytes, 10 * 1000 * 1000)
|
|
1095
|
+
assert.strictEqual(vault.registry.lastScan.candidate_min_bytes, 10 * 1000 * 1000)
|
|
1096
|
+
assert.strictEqual(vault.registry.duplicates.size, 1)
|
|
1097
|
+
const reloaded = new Registry(vault.root)
|
|
1098
|
+
await reloaded.load()
|
|
1099
|
+
assert.strictEqual(reloaded.settings.candidate_min_bytes, 10 * 1000 * 1000)
|
|
1100
|
+
})
|
|
1101
|
+
|
|
1102
|
+
test('HF hash-free ingestion falls back to the generic walker for every other entry', async () => {
|
|
1103
|
+
const { home, vault } = await makeEnv()
|
|
1104
|
+
const blobDir = path.resolve(home, 'api', 'appA', 'cache', 'models--org--model', 'blobs')
|
|
1105
|
+
const ordinary = await writeFile(path.resolve(blobDir, 'model.bin'), Buffer.alloc(4096, 7))
|
|
1106
|
+
const nested = await writeFile(path.resolve(blobDir, 'nested', 'model.bin'), Buffer.alloc(4096, 8))
|
|
1107
|
+
|
|
1108
|
+
await vault.sweeper.scan()
|
|
1109
|
+
|
|
1110
|
+
assert.ok(vault.registry.scanIndex.has(ordinary))
|
|
1111
|
+
assert.ok(vault.registry.scanIndex.has(nested))
|
|
1112
|
+
assert.strictEqual(vault.registry.lastScan.candidates, 2)
|
|
1113
|
+
assert.strictEqual(vault.registry.lastScan.hashed, 2)
|
|
1114
|
+
})
|
|
1115
|
+
|
|
1116
|
+
test('a repaired linked inode without a trustworthy snapshot is hashed once', async () => {
|
|
1117
|
+
const { home, vault } = await makeEnv()
|
|
1118
|
+
const original = Buffer.alloc(4096, 10)
|
|
1119
|
+
const changed = Buffer.alloc(4096, 11)
|
|
1120
|
+
const file = await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), original)
|
|
1121
|
+
await vault.sweeper.scan()
|
|
1122
|
+
const oldHash = sha256(original)
|
|
1123
|
+
|
|
1124
|
+
await fs.promises.writeFile(file, changed)
|
|
1125
|
+
await vault.rebuild()
|
|
1126
|
+
assert.strictEqual(vault.registry.scanIndex.has(file), false, 'Repair leaves changed content unverified')
|
|
1127
|
+
await vault.sweeper.scan()
|
|
1128
|
+
|
|
1129
|
+
assert.strictEqual(vault.registry.lastScan.hashed, 1)
|
|
1130
|
+
assert.strictEqual(vault.registry.blobs.has(oldHash), false)
|
|
1131
|
+
assert.ok(vault.registry.blobs.has(sha256(changed)))
|
|
1132
|
+
assert.strictEqual(vault.registry.scanIndex.get(file).hash, sha256(changed))
|
|
1133
|
+
})
|
|
1134
|
+
|
|
1135
|
+
test('scan-cache identity includes the filesystem device', async () => {
|
|
1136
|
+
const { home, vault } = await makeEnv()
|
|
1137
|
+
const file = await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), Buffer.alloc(4096, 12))
|
|
1138
|
+
await vault.sweeper.scan()
|
|
1139
|
+
vault.registry.scanIndex.get(file).dev = `other:${vault.registry.scanIndex.get(file).dev}`
|
|
1140
|
+
|
|
1141
|
+
await vault.sweeper.scan()
|
|
1142
|
+
|
|
1143
|
+
assert.strictEqual(vault.registry.lastScan.hashed, 1)
|
|
1144
|
+
})
|
|
1145
|
+
|
|
1146
|
+
test('an unrelated unresolved action does not block app conversion', async () => {
|
|
1147
|
+
const { home, vault, kernel } = await makeEnv()
|
|
1148
|
+
const content = Buffer.alloc(4096, 13)
|
|
1149
|
+
await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), content)
|
|
1150
|
+
await writeFile(path.resolve(home, 'api', 'appB', 'm.bin'), content)
|
|
1151
|
+
await vault.sweeper.scan()
|
|
1152
|
+
const pending = [...vault.registry.duplicates.values()][0]
|
|
1153
|
+
kernel.api = { running: { 'custom-action-id': true } }
|
|
1154
|
+
|
|
1155
|
+
const pendingPath = [...vault.registry.duplicates]
|
|
1156
|
+
.find(([, entry]) => entry === pending)[0]
|
|
1157
|
+
const result = await vault.perform('deduplicate', {
|
|
1158
|
+
scope_id: pending.source_id,
|
|
1159
|
+
paths: [pendingPath]
|
|
1160
|
+
})
|
|
1161
|
+
|
|
1162
|
+
assert.strictEqual(result.converted, 1)
|
|
1163
|
+
})
|
|
1164
|
+
|
|
1165
|
+
test('a custom running id blocks only the app that owns its script path', async () => {
|
|
1166
|
+
const { home, vault, kernel } = await makeEnv()
|
|
1167
|
+
const content = Buffer.alloc(4096, 14)
|
|
1168
|
+
await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), content)
|
|
1169
|
+
await writeFile(path.resolve(home, 'api', 'appB', 'm.bin'), content)
|
|
1170
|
+
await vault.sweeper.scan()
|
|
1171
|
+
const pending = [...vault.registry.duplicates.values()][0]
|
|
1172
|
+
const source = vault.sources().find((item) => item.id === pending.source_id)
|
|
1173
|
+
kernel.api = {
|
|
1174
|
+
running: { 'custom-action-id': true },
|
|
1175
|
+
running_paths: { 'custom-action-id': path.resolve(source.root, 'start.js') }
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const result = await vault.perform('deduplicate', { scope_id: pending.source_id })
|
|
1179
|
+
|
|
1180
|
+
assert.match(result.error, /running script/i)
|
|
1181
|
+
assert.strictEqual(vault.registry.duplicates.size, 1)
|
|
1182
|
+
})
|
|
1183
|
+
|
|
274
1184
|
test('in-place mutation propagates to all names; scan detects divergence and evicts', async () => {
|
|
275
1185
|
const { home, vault } = await makeEnv()
|
|
276
1186
|
const original = crypto.randomBytes(4096)
|
|
@@ -294,6 +1204,97 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
294
1204
|
assert.ok(events.some((e) => e.kind === 'diverged' && e.hash === oldHash))
|
|
295
1205
|
})
|
|
296
1206
|
|
|
1207
|
+
test('a fatal scan after divergence preserves the pre-scan store name and registry', async () => {
|
|
1208
|
+
const { home, vault } = await makeEnv()
|
|
1209
|
+
const original = crypto.randomBytes(4096)
|
|
1210
|
+
const oldHash = sha256(original)
|
|
1211
|
+
const a = await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), original)
|
|
1212
|
+
await vault.adopt(a, oldHash, { app: 'appA' })
|
|
1213
|
+
const b = path.resolve(home, 'api', 'appB', 'm.bin')
|
|
1214
|
+
await writeFile(b, original)
|
|
1215
|
+
await vault.convert(b, oldHash, { app: 'appB' })
|
|
1216
|
+
await vault.registry.flush()
|
|
1217
|
+
|
|
1218
|
+
await fs.promises.writeFile(b, crypto.randomBytes(4096))
|
|
1219
|
+
const realVerify = vault.verify.bind(vault)
|
|
1220
|
+
vault.verify = async () => {
|
|
1221
|
+
const error = new Error('temporary verification failure')
|
|
1222
|
+
error.code = 'EIO'
|
|
1223
|
+
throw error
|
|
1224
|
+
}
|
|
1225
|
+
try {
|
|
1226
|
+
await assert.rejects(vault.sweeper.scan(), (error) => error && error.code === 'EIO')
|
|
1227
|
+
} finally {
|
|
1228
|
+
vault.verify = realVerify
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const [storeStat, visibleStat] = await Promise.all([
|
|
1232
|
+
fs.promises.stat(vault.storePathFor(oldHash)),
|
|
1233
|
+
fs.promises.stat(a)
|
|
1234
|
+
])
|
|
1235
|
+
assert.strictEqual(storeStat.dev, visibleStat.dev)
|
|
1236
|
+
assert.strictEqual(storeStat.ino, visibleStat.ino)
|
|
1237
|
+
assert.ok(vault.registry.blobs.has(oldHash))
|
|
1238
|
+
assert.strictEqual([...vault.registry.links.values()]
|
|
1239
|
+
.filter((entry) => entry.hash === oldHash).length, 2)
|
|
1240
|
+
})
|
|
1241
|
+
|
|
1242
|
+
test('partial retired-store rollback refreshes final metadata after relinking', async () => {
|
|
1243
|
+
const { home, vault } = await makeEnv()
|
|
1244
|
+
const first = crypto.randomBytes(4096)
|
|
1245
|
+
const second = crypto.randomBytes(4096)
|
|
1246
|
+
const firstPath = await writeFile(path.resolve(home, 'api', 'appA', 'first.bin'), first)
|
|
1247
|
+
const secondPath = await writeFile(path.resolve(home, 'api', 'appB', 'second.bin'), second)
|
|
1248
|
+
const firstHash = sha256(first)
|
|
1249
|
+
const secondHash = sha256(second)
|
|
1250
|
+
await vault.adopt(firstPath, firstHash, { app: 'appA' })
|
|
1251
|
+
await vault.adopt(secondPath, secondHash, { app: 'appB' })
|
|
1252
|
+
const firstStore = vault.storePathFor(firstHash)
|
|
1253
|
+
const secondStore = vault.storePathFor(secondHash)
|
|
1254
|
+
const [firstStoreStat, secondStoreStat] = await Promise.all([
|
|
1255
|
+
fs.promises.stat(firstStore),
|
|
1256
|
+
fs.promises.stat(secondStore)
|
|
1257
|
+
])
|
|
1258
|
+
const registrySnapshot = vault.registry.snapshotState()
|
|
1259
|
+
|
|
1260
|
+
vault.beginScanDraft()
|
|
1261
|
+
vault.rememberRetiredScanStore(firstStore, firstStoreStat, [firstPath])
|
|
1262
|
+
vault.rememberRetiredScanStore(secondStore, secondStoreStat, [secondPath])
|
|
1263
|
+
vault.registry.untrack(firstPath)
|
|
1264
|
+
vault.registry.removeBlob(firstHash)
|
|
1265
|
+
vault.registry.untrack(secondPath)
|
|
1266
|
+
vault.registry.removeBlob(secondHash)
|
|
1267
|
+
|
|
1268
|
+
const realUnlink = fs.promises.unlink
|
|
1269
|
+
let retiredUnlinks = 0
|
|
1270
|
+
fs.promises.unlink = async (target) => {
|
|
1271
|
+
if (target === firstStore || target === secondStore) {
|
|
1272
|
+
retiredUnlinks += 1
|
|
1273
|
+
if (retiredUnlinks === 2) {
|
|
1274
|
+
const error = new Error('temporary store removal failure')
|
|
1275
|
+
error.code = 'EIO'
|
|
1276
|
+
throw error
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
return realUnlink(target)
|
|
1280
|
+
}
|
|
1281
|
+
try {
|
|
1282
|
+
await assert.rejects(vault.commitScanDraft(), (error) => error && error.code === 'EIO')
|
|
1283
|
+
} finally {
|
|
1284
|
+
fs.promises.unlink = realUnlink
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const restored = await fs.promises.stat(firstStore)
|
|
1288
|
+
assert.strictEqual(restored.dev, firstStoreStat.dev)
|
|
1289
|
+
assert.strictEqual(restored.ino, firstStoreStat.ino)
|
|
1290
|
+
assert.strictEqual(await vault.abortScanDraft(registrySnapshot), true)
|
|
1291
|
+
const visible = await fs.promises.stat(firstPath)
|
|
1292
|
+
const indexed = vault.registry.scanIndex.get(firstPath)
|
|
1293
|
+
assert.strictEqual(indexed.dev, visible.dev)
|
|
1294
|
+
assert.strictEqual(indexed.ino, visible.ino)
|
|
1295
|
+
assert.strictEqual(indexed.ctime, visible.ctimeMs)
|
|
1296
|
+
})
|
|
1297
|
+
|
|
297
1298
|
test('scan of a 10k-entry folder at steady state finishes fast with zero reads', async () => {
|
|
298
1299
|
const { home, vault } = await makeEnv()
|
|
299
1300
|
const dir = path.resolve(home, 'api', 'bigapp', 'stuff')
|
|
@@ -306,7 +1307,7 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
306
1307
|
await vault.sweeper.scan()
|
|
307
1308
|
const elapsed = Date.now() - started
|
|
308
1309
|
assert.ok(elapsed < 2000, `steady-state rescan took ${elapsed}ms`)
|
|
309
|
-
assert.strictEqual(vault.
|
|
1310
|
+
assert.strictEqual(vault.registry.lastScan.hashed, 0)
|
|
310
1311
|
})
|
|
311
1312
|
|
|
312
1313
|
test('locked conversions stay pending and can be retried', async () => {
|
|
@@ -316,7 +1317,14 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
316
1317
|
const a = await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), content)
|
|
317
1318
|
await vault.adopt(a, hash, { app: 'appA' })
|
|
318
1319
|
const b = await writeFile(path.resolve(home, 'api', 'appB', 'm.bin'), content)
|
|
319
|
-
vault.
|
|
1320
|
+
await vault.refreshSources()
|
|
1321
|
+
const appB = vault.sources().find((source) => source.kind === 'app' && source.app === 'appB')
|
|
1322
|
+
const pendingStat = await fs.promises.stat(b)
|
|
1323
|
+
vault.registry.duplicates.set(b, {
|
|
1324
|
+
hash, size: content.length, app: 'appB', source_id: appB.id,
|
|
1325
|
+
dev: pendingStat.dev, ino: pendingStat.ino,
|
|
1326
|
+
mtime: pendingStat.mtimeMs, ctime: pendingStat.ctimeMs
|
|
1327
|
+
})
|
|
320
1328
|
|
|
321
1329
|
const realConvert = vault.convert.bind(vault)
|
|
322
1330
|
let calls = 0
|
|
@@ -325,10 +1333,10 @@ describe('vault manual scan (phase 3)', () => {
|
|
|
325
1333
|
if (calls === 1) return { status: 'locked' }
|
|
326
1334
|
return realConvert(...args)
|
|
327
1335
|
}
|
|
328
|
-
const first = await vault.
|
|
1336
|
+
const first = await vault.perform('deduplicate', { scope_id: appB.id, paths: [b] })
|
|
329
1337
|
assert.strictEqual(first.locked, 1)
|
|
330
1338
|
assert.ok(vault.registry.duplicates.has(b), 'still pending after lock')
|
|
331
|
-
const second = await vault.
|
|
1339
|
+
const second = await vault.perform('deduplicate', { scope_id: appB.id, paths: [b] })
|
|
332
1340
|
vault.convert = realConvert
|
|
333
1341
|
assert.strictEqual(second.converted, 1)
|
|
334
1342
|
assert.strictEqual((await fs.promises.stat(b)).ino, (await fs.promises.stat(a)).ino)
|