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.
Files changed (37) hide show
  1. package/kernel/api/hf/index.js +2 -2
  2. package/kernel/api/index.js +12 -3
  3. package/kernel/connect/index.js +2 -2
  4. package/kernel/connect/providers/huggingface/index.js +14 -5
  5. package/kernel/index.js +14 -2
  6. package/kernel/shell.js +3 -2
  7. package/kernel/shells.js +6 -0
  8. package/kernel/vault/constants.js +13 -0
  9. package/kernel/vault/hash_worker.js +9 -2
  10. package/kernel/vault/index.js +1856 -316
  11. package/kernel/vault/registry.js +526 -52
  12. package/kernel/vault/snapshot.js +29 -0
  13. package/kernel/vault/sweeper.js +480 -210
  14. package/kernel/vault/walker.js +142 -0
  15. package/package.json +1 -1
  16. package/server/index.js +79 -90
  17. package/server/lib/privacy_filter_cache.js +4 -1
  18. package/server/public/storage-size.js +12 -0
  19. package/server/public/style.css +13 -0
  20. package/server/public/tab-link-popover.js +3 -0
  21. package/server/public/vault-nav.js +96 -0
  22. package/server/public/vault.css +1079 -0
  23. package/server/public/vault.js +1835 -0
  24. package/server/views/app.ejs +93 -16
  25. package/server/views/connect/huggingface.ejs +7 -9
  26. package/server/views/partials/main_sidebar.ejs +6 -1
  27. package/server/views/partials/vault_workspace.ejs +55 -0
  28. package/server/views/vault.ejs +5 -1532
  29. package/server/views/vault_app.ejs +22 -0
  30. package/test/hf-api.test.js +4 -2
  31. package/test/huggingface-connect.test.js +7 -11
  32. package/test/huggingface-token-validity.test.js +135 -0
  33. package/test/plugin-action-functions.test.js +19 -0
  34. package/test/privacy-filter-cache.test.js +1 -0
  35. package/test/vault-engine.test.js +1276 -26
  36. package/test/vault-sweep.test.js +1043 -35
  37. package/test/vault-ui.test.js +2184 -65
@@ -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.deepStrictEqual(vault.registry.lastScan.source_ids, ['pinokio'], 'scan source set is recorded for later estimates')
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
- const summary = await vault.sweeper.convertPending(undefined)
75
- assert.strictEqual(summary.converted, 2)
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('top-level linked imports are scanned as external sources while nested links stay skipped', async (t) => {
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
- const nested = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-external-nested-'))
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
- const canonicalNestedFile = path.resolve(await fs.promises.realpath(nested), 'should-not-scan.bin')
117
- assert.ok(source, 'linked import becomes an external source')
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.ok(status.sources.some((item) => item.id === source.id && item.kind === 'external'))
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(status.last_scan.source_bytes[source.id], content.length, 'external totals remain attributable')
296
+ assert.strictEqual(fs.existsSync(imported), true)
129
297
  })
130
298
 
131
- test('adding an external source creates one persistent import without starting a scan', async () => {
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.strictEqual((await fs.promises.lstat(added.source.mount_path)).isSymbolicLink(), true)
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 imported twice')
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
- assert.ok(vault.registry.scanIndex.has(path.resolve(await fs.promises.realpath(imported))))
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.sweeper.stats.hashes, after)
173
- assert.strictEqual(vault.sweeper.state.estimated_files, priorFiles, 'same-source rescan uses the prior exact file count')
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 realStat = fs.promises.stat
767
+ const realLstat = fs.promises.lstat
182
768
  let active = 0
183
769
  let maximum = 0
184
- fs.promises.stat = async (target) => {
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 realStat(target)
776
+ return await realLstat(target)
191
777
  } finally {
192
778
  active -= 1
193
779
  }
194
780
  }
195
- return realStat(target)
781
+ return realLstat(target)
196
782
  }
197
783
  try {
198
784
  await vault.sweeper.scan()
199
785
  } finally {
200
- fs.promises.stat = realStat
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.sweeper.stats.hashes, 0, 'hash harvested, not computed')
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.sweeper.stats.hashes, 0)
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.registry.duplicates.set(b, { hash, size: content.length, app: 'appB' })
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.sweeper.convertPending('appB')
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.sweeper.convertPending('appB')
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)