pinokiod 8.0.36 → 8.0.38

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.
@@ -0,0 +1,820 @@
1
+ const fs = require('fs')
2
+ const os = require('os')
3
+ const path = require('path')
4
+ const crypto = require('crypto')
5
+ const { Worker } = require('worker_threads')
6
+ const Registry = require('./registry')
7
+ const Sweeper = require('./sweeper')
8
+
9
+ // Shared model store engine (spec/requirements/shared-model-store.md).
10
+ // Store + registry + volume probe + hashing + adopt/convert/verify, plus the
11
+ // user-facing operations behind the vault dashboard (detach, undo, reclaim).
12
+ // Nothing here runs automatically except startup verify; discovery happens
13
+ // only through the user's manual scan.
14
+
15
+ const SIZE_THRESHOLD = 100 * 1024 * 1024
16
+ const TMP_SUFFIX = ".pinokio-dedup-tmp"
17
+ const NO_LINK_CODES = new Set(["EXDEV", "ENOTSUP", "EPERM", "ENOSYS"])
18
+ const LOCK_CODES = new Set(["EBUSY", "EPERM", "EACCES"])
19
+ const STAT_CONCURRENCY = 32
20
+
21
+ const isPathWithin = (root, target) => {
22
+ const rel = path.relative(path.resolve(root), path.resolve(target))
23
+ return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel))
24
+ }
25
+
26
+ const samePath = (left, right) => {
27
+ const a = path.resolve(left)
28
+ const b = path.resolve(right)
29
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b
30
+ }
31
+
32
+ const sourceId = (kind, name) => `${kind}:${encodeURIComponent(name)}`
33
+
34
+ const statMany = async (paths, concurrency = STAT_CONCURRENCY) => {
35
+ const results = new Array(paths.length)
36
+ let cursor = 0
37
+ const workers = Array.from({ length: Math.min(concurrency, paths.length) }, async () => {
38
+ while (cursor < paths.length) {
39
+ const index = cursor++
40
+ try {
41
+ results[index] = await fs.promises.stat(paths[index])
42
+ } catch (error) {
43
+ results[index] = null
44
+ }
45
+ }
46
+ })
47
+ await Promise.all(workers)
48
+ return results
49
+ }
50
+
51
+ class Vault {
52
+ constructor(kernel) {
53
+ this.kernel = kernel
54
+ this.enabled = false
55
+ this.initialized = false
56
+ this.mode = null // 'link' | 'copy' for the vault's own volume
57
+ this.volumeModes = new Map() // dev -> 'link' | 'copy'
58
+ this.registry = null
59
+ this.worker = null
60
+ this.workerJobs = new Map()
61
+ this.workerSeq = 0
62
+ this.workerStarts = 0
63
+ this.workerIdleTimer = null
64
+ this.workerIdleMs = 750
65
+ this.statConcurrency = STAT_CONCURRENCY
66
+ this.sizeThreshold = SIZE_THRESHOLD
67
+ this._sources = []
68
+ }
69
+ get root() {
70
+ return path.resolve(this.kernel.homedir, "vault")
71
+ }
72
+ get blobRoot() {
73
+ return path.resolve(this.root, "sha256")
74
+ }
75
+ storePathFor(hash) {
76
+ return path.resolve(this.blobRoot, hash.slice(0, 2), hash)
77
+ }
78
+ async refreshSources() {
79
+ const home = path.resolve(this.kernel.homedir)
80
+ const apiRoot = path.resolve(home, "api")
81
+ const sources = [
82
+ { id: "pinokio", kind: "pinokio", label: "Pinokio", root: home, parent_id: null },
83
+ { id: "apps", kind: "virtual", label: "Apps", root: apiRoot, parent_id: "pinokio" },
84
+ { id: "external", kind: "virtual", label: "External folders", root: null, parent_id: null }
85
+ ]
86
+ let storeDev = null
87
+ try { storeDev = (await fs.promises.stat(this.root)).dev } catch (e) {}
88
+ const decorate = async (source) => {
89
+ if (!source.root) return source
90
+ try {
91
+ const st = await fs.promises.stat(source.root)
92
+ source.dev = st.dev
93
+ source.available = st.isDirectory()
94
+ source.shareable = source.available && storeDev !== null && st.dev === storeDev && this.mode === "link"
95
+ } catch (e) {
96
+ source.available = false
97
+ source.shareable = false
98
+ }
99
+ return source
100
+ }
101
+
102
+ let apiEntries = []
103
+ try { apiEntries = await fs.promises.readdir(apiRoot, { withFileTypes: true }) } catch (e) {}
104
+ const seenExternalRoots = new Set()
105
+ for (const entry of apiEntries) {
106
+ const mountPath = path.resolve(apiRoot, entry.name)
107
+ if (entry.isSymbolicLink()) {
108
+ try {
109
+ const root = await fs.promises.realpath(mountPath)
110
+ const st = await fs.promises.stat(root)
111
+ if (!st.isDirectory()) continue
112
+ const canonical = path.resolve(root)
113
+ if (seenExternalRoots.has(canonical)) continue
114
+ seenExternalRoots.add(canonical)
115
+ sources.push(await decorate({
116
+ id: sourceId("external", entry.name), kind: "external", label: entry.name,
117
+ root: canonical, mount_path: mountPath, parent_id: "external"
118
+ }))
119
+ } catch (e) {}
120
+ } else if (entry.isDirectory()) {
121
+ sources.push(await decorate({
122
+ id: sourceId("app", entry.name), kind: "app", label: entry.name,
123
+ app: entry.name, root: mountPath, parent_id: "apps"
124
+ }))
125
+ }
126
+ }
127
+
128
+ let homeEntries = []
129
+ try { homeEntries = await fs.promises.readdir(home, { withFileTypes: true }) } catch (e) {}
130
+ for (const entry of homeEntries) {
131
+ if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name === "api" || entry.name === "vault") continue
132
+ sources.push(await decorate({
133
+ id: sourceId("folder", entry.name), kind: "folder", label: entry.name,
134
+ root: path.resolve(home, entry.name), parent_id: "pinokio"
135
+ }))
136
+ }
137
+ const homeSource = sources.find((source) => source.id === "pinokio")
138
+ if (homeSource) await decorate(homeSource)
139
+ this._sources = sources
140
+ return sources
141
+ }
142
+ sources() {
143
+ return this._sources
144
+ }
145
+ async addExternalSource(folderPath) {
146
+ if (typeof folderPath !== "string" || !path.isAbsolute(folderPath.trim())) {
147
+ throw new Error("Choose a valid folder.")
148
+ }
149
+ const requested = folderPath.trim()
150
+ let canonical
151
+ let stats
152
+ try {
153
+ canonical = path.resolve(await fs.promises.realpath(requested))
154
+ stats = await fs.promises.stat(canonical)
155
+ } catch (error) {
156
+ throw new Error("That folder is no longer available.")
157
+ }
158
+ if (!stats.isDirectory()) throw new Error("Choose a folder, not a file.")
159
+
160
+ const home = path.resolve(this.kernel.homedir)
161
+ let canonicalHome = home
162
+ try { canonicalHome = path.resolve(await fs.promises.realpath(home)) } catch (error) {}
163
+ if (isPathWithin(canonicalHome, canonical) || isPathWithin(canonical, canonicalHome)) {
164
+ throw new Error("That folder is already inside Pinokio and is included in scans.")
165
+ }
166
+
167
+ await this.refreshSources()
168
+ const existing = this._sources.find((source) => source.kind === "external" && source.root && samePath(source.root, canonical))
169
+ if (existing) {
170
+ return { created: false, source: existing }
171
+ }
172
+
173
+ const apiRoot = path.resolve(home, "api")
174
+ await fs.promises.mkdir(apiRoot, { recursive: true })
175
+ const baseLabel = path.basename(canonical) || "external-folder"
176
+ let mountPath = null
177
+ for (let index = 1; index < 10000; index++) {
178
+ const label = index === 1 ? baseLabel : `${baseLabel}-${index}`
179
+ const candidate = path.resolve(apiRoot, label)
180
+ try {
181
+ await fs.promises.symlink(canonical, candidate, this.kernel.platform === "win32" ? "junction" : "dir")
182
+ mountPath = candidate
183
+ break
184
+ } catch (error) {
185
+ if (error && error.code === "EEXIST") continue
186
+ if (error && (error.code === "EACCES" || error.code === "EPERM")) {
187
+ throw new Error("Pinokio does not have permission to add that folder.")
188
+ }
189
+ throw new Error("Pinokio could not add that folder.")
190
+ }
191
+ }
192
+ if (!mountPath) throw new Error("Pinokio could not create a unique name for that folder.")
193
+
194
+ await this.refreshSources()
195
+ const source = this._sources.find((item) => item.kind === "external" && item.mount_path && samePath(item.mount_path, mountPath))
196
+ if (!source) throw new Error("The folder was added but could not be loaded. Restart Pinokio and try again.")
197
+ return { created: true, source }
198
+ }
199
+ sourceForPath(filePath, preferredId) {
200
+ const sources = this._sources || []
201
+ if (preferredId) {
202
+ const preferred = sources.find((source) => source.id === preferredId)
203
+ if (preferred) return preferred
204
+ }
205
+ const absolute = path.resolve(filePath)
206
+ const concrete = sources.filter((source) => source.root && source.kind !== "virtual")
207
+ .filter((source) => isPathWithin(source.root, absolute) || (source.mount_path && isPathWithin(source.mount_path, absolute)))
208
+ .sort((a, b) => Math.max((b.root || "").length, (b.mount_path || "").length) -
209
+ Math.max((a.root || "").length, (a.mount_path || "").length))
210
+ return concrete[0] || sources.find((source) => source.id === "pinokio") || null
211
+ }
212
+ locationForPath(filePath, preferredId) {
213
+ const source = this.sourceForPath(filePath, preferredId)
214
+ if (!source) return { source_id: null, relative_path: path.basename(filePath) }
215
+ const absolute = path.resolve(filePath)
216
+ let base = source.root
217
+ if (source.mount_path && isPathWithin(source.mount_path, absolute)) base = source.mount_path
218
+ const relative = path.relative(base, absolute).split(path.sep).join("/") || path.basename(absolute)
219
+ return {
220
+ source_id: source.id,
221
+ source_kind: source.kind,
222
+ source_label: source.label,
223
+ relative_path: relative
224
+ }
225
+ }
226
+ scanRoots() {
227
+ const home = path.resolve(this.kernel.homedir)
228
+ const roots = [{ root: home, source_id: "pinokio" }]
229
+ const seen = new Set([home])
230
+ for (const source of this._sources) {
231
+ if (source.kind !== "external" || !source.available || !source.root) continue
232
+ const canonical = path.resolve(source.root)
233
+ if (isPathWithin(home, canonical) || isPathWithin(canonical, home) || seen.has(canonical)) continue
234
+ seen.add(canonical)
235
+ roots.push({ root: canonical, source_id: source.id })
236
+ }
237
+ return roots
238
+ }
239
+ // Kill switch: system ENVIRONMENT variable PINOKIO_VAULT, default unset =
240
+ // enabled. Read directly (single key) to avoid loading the environment
241
+ // module chain before the kernel is fully up.
242
+ async isEnabled() {
243
+ let value = process.env.PINOKIO_VAULT
244
+ if (value === undefined && this.kernel.homedir) {
245
+ try {
246
+ const raw = await fs.promises.readFile(path.resolve(this.kernel.homedir, "ENVIRONMENT"), "utf8")
247
+ for (const line of raw.split("\n")) {
248
+ const m = line.match(/^\s*PINOKIO_VAULT\s*=\s*(.*)\s*$/)
249
+ if (m) value = m[1].trim()
250
+ }
251
+ } catch (e) {}
252
+ }
253
+ return String(value).toLowerCase() !== "false"
254
+ }
255
+ // Disabled ⇒ do nothing observable: no directory, no registry, no probes.
256
+ async init() {
257
+ this.enabled = await this.isEnabled()
258
+ if (!this.enabled) return { enabled: false }
259
+ await fs.promises.mkdir(this.blobRoot, { recursive: true })
260
+ this.registry = new Registry(this.root)
261
+ this.mode = await this.probe(this.root)
262
+ await this.refreshSources()
263
+ const loaded = await this.registry.load()
264
+ if (loaded.corrupt) {
265
+ await this.rebuild(undefined, { preserveState: false })
266
+ }
267
+ this.sweeper = new Sweeper(this)
268
+ this.initialized = true
269
+ return { enabled: true, mode: this.mode, corrupt_recovered: !!loaded.corrupt }
270
+ }
271
+ // Capability probe: temp file + node:fs.link + nlink check. Once per volume.
272
+ async probe(dir) {
273
+ let dev
274
+ try {
275
+ dev = (await fs.promises.stat(dir)).dev
276
+ if (this.volumeModes.has(dev)) return this.volumeModes.get(dev)
277
+ } catch (e) {
278
+ return "copy"
279
+ }
280
+ const a = path.resolve(dir, `.pinokio-probe-${crypto.randomBytes(6).toString("hex")}`)
281
+ const b = a + "-link"
282
+ let mode = "copy"
283
+ try {
284
+ await fs.promises.writeFile(a, "probe")
285
+ await fs.promises.link(a, b)
286
+ const st = await fs.promises.stat(b)
287
+ if (st.nlink === 2) mode = "link"
288
+ } catch (e) {
289
+ } finally {
290
+ await fs.promises.unlink(a).catch(() => {})
291
+ await fs.promises.unlink(b).catch(() => {})
292
+ }
293
+ this.volumeModes.set(dev, mode)
294
+ return mode
295
+ }
296
+ async hashFile(filePath) {
297
+ if (this.workerIdleTimer) {
298
+ clearTimeout(this.workerIdleTimer)
299
+ this.workerIdleTimer = null
300
+ }
301
+ if (!this.worker) {
302
+ const worker = new Worker(path.resolve(__dirname, "hash_worker.js"))
303
+ this.worker = worker
304
+ this.workerStarts += 1
305
+ worker.unref()
306
+ worker.on("message", ({ id, hash, size, error }) => {
307
+ const job = this.workerJobs.get(id)
308
+ if (!job) return
309
+ this.workerJobs.delete(id)
310
+ if (error) job.reject(new Error(error))
311
+ else job.resolve({ hash, size })
312
+ // Keep the worker warm across the scan queue. Starting one worker per
313
+ // file costs ~30ms and adds up quickly on a first scan.
314
+ if (this.workerJobs.size === 0 && this.worker === worker) {
315
+ this.workerIdleTimer = setTimeout(() => {
316
+ this.workerIdleTimer = null
317
+ if (this.workerJobs.size === 0 && this.worker === worker) {
318
+ this.worker = null
319
+ worker.terminate().catch(() => {})
320
+ }
321
+ }, this.workerIdleMs)
322
+ if (this.workerIdleTimer.unref) this.workerIdleTimer.unref()
323
+ }
324
+ })
325
+ worker.on("error", (error) => {
326
+ if (this.worker !== worker) return
327
+ if (this.workerIdleTimer) clearTimeout(this.workerIdleTimer)
328
+ this.workerIdleTimer = null
329
+ for (const job of this.workerJobs.values()) job.reject(error)
330
+ this.workerJobs.clear()
331
+ this.worker = null
332
+ })
333
+ worker.on("exit", (code) => {
334
+ if (this.worker !== worker) return
335
+ if (this.workerIdleTimer) clearTimeout(this.workerIdleTimer)
336
+ this.workerIdleTimer = null
337
+ for (const job of this.workerJobs.values()) {
338
+ job.reject(new Error(`hash worker exited with code ${code}`))
339
+ }
340
+ this.workerJobs.clear()
341
+ this.worker = null
342
+ })
343
+ }
344
+ const id = ++this.workerSeq
345
+ return new Promise((resolve, reject) => {
346
+ this.workerJobs.set(id, { resolve, reject })
347
+ this.worker.postMessage({ id, filePath })
348
+ })
349
+ }
350
+ // adopt: give an existing file a store name. Metadata-only, never copies.
351
+ async adopt(filePath, hash, meta = {}) {
352
+ if (!this.enabled) return { status: "disabled" }
353
+ const storePath = this.storePathFor(hash)
354
+ const st = await fs.promises.stat(filePath)
355
+ const linkEntry = {
356
+ hash,
357
+ app: meta.app || null,
358
+ source_id: meta.source_id || null,
359
+ dev: st.dev,
360
+ ino: st.ino,
361
+ mode: "link"
362
+ }
363
+ let storeStat = null
364
+ try {
365
+ storeStat = await fs.promises.stat(storePath)
366
+ } catch (e) {}
367
+ if (storeStat) {
368
+ if (storeStat.dev === st.dev && storeStat.ino === st.ino) {
369
+ this.registry.addBlob(hash, { size: st.size, source_urls: meta.source_urls || [] })
370
+ this.registry.addLink(filePath, linkEntry)
371
+ return { status: "already" }
372
+ }
373
+ return { status: "duplicate", storePath }
374
+ }
375
+ try {
376
+ await fs.promises.mkdir(path.dirname(storePath), { recursive: true })
377
+ await fs.promises.link(filePath, storePath)
378
+ } catch (e) {
379
+ if (NO_LINK_CODES.has(e.code)) {
380
+ linkEntry.mode = "copy"
381
+ this.registry.addBlob(hash, { size: st.size, source_urls: meta.source_urls || [] })
382
+ this.registry.addLink(filePath, linkEntry)
383
+ await this.registry.appendEvent({ kind: "adopt", hash, path: filePath, app: meta.app || null, source_id: meta.source_id || null, bytes_saved: 0, mode: "copy" })
384
+ return { status: "copy-mode" }
385
+ }
386
+ throw e
387
+ }
388
+ this.registry.addBlob(hash, { size: st.size, source_urls: meta.source_urls || [] })
389
+ this.registry.addLink(filePath, linkEntry)
390
+ await this.registry.appendEvent({ kind: "adopt", hash, path: filePath, app: meta.app || null, source_id: meta.source_id || null, bytes_saved: 0 })
391
+ return { status: "adopted" }
392
+ }
393
+ // convert: replace a byte-identical duplicate with a link to an existing
394
+ // blob. Atomic link+rename; target is never missing at any instant.
395
+ async convert(targetPath, hash, meta = {}) {
396
+ if (!this.enabled) return { status: "disabled" }
397
+ const storePath = this.storePathFor(hash)
398
+ let storeStat
399
+ try {
400
+ storeStat = await fs.promises.stat(storePath)
401
+ } catch (e) {
402
+ return { status: "no-blob" }
403
+ }
404
+ const targetStat = await fs.promises.stat(targetPath)
405
+ if (storeStat.dev === targetStat.dev && storeStat.ino === targetStat.ino) {
406
+ this.registry.addLink(targetPath, { hash, app: meta.app || null, source_id: meta.source_id || null, dev: storeStat.dev, ino: storeStat.ino, mode: "link" })
407
+ return { status: "already" }
408
+ }
409
+ if (storeStat.size !== targetStat.size) {
410
+ return { status: "size-mismatch" }
411
+ }
412
+ const tmp = targetPath + TMP_SUFFIX
413
+ try {
414
+ try {
415
+ await fs.promises.link(storePath, tmp)
416
+ } catch (e) {
417
+ if (e.code === "EEXIST") {
418
+ await fs.promises.unlink(tmp)
419
+ await fs.promises.link(storePath, tmp)
420
+ } else {
421
+ throw e
422
+ }
423
+ }
424
+ await fs.promises.rename(tmp, targetPath)
425
+ } catch (e) {
426
+ await fs.promises.unlink(tmp).catch(() => {})
427
+ if (LOCK_CODES.has(e.code)) {
428
+ return { status: "locked" }
429
+ }
430
+ if (NO_LINK_CODES.has(e.code)) {
431
+ return { status: "unavailable", code: e.code }
432
+ }
433
+ throw e
434
+ }
435
+ const st = await fs.promises.stat(targetPath)
436
+ this.registry.addLink(targetPath, { hash, app: meta.app || null, source_id: meta.source_id || null, dev: st.dev, ino: st.ino, mode: "link" })
437
+ this.registry.addSaved(storeStat.size)
438
+ await this.registry.appendEvent({
439
+ kind: "convert", hash, path: targetPath, app: meta.app || null, source_id: meta.source_id || null,
440
+ auto: !!meta.auto, bytes_saved: storeStat.size, batch_id: meta.batch_id || null
441
+ })
442
+ return { status: "converted", bytes_saved: storeStat.size }
443
+ }
444
+ // reclaim: delete an orphan's store name. Refuses when any app name remains.
445
+ async reclaim(hash) {
446
+ if (!this.enabled) return { status: "disabled" }
447
+ const storePath = this.storePathFor(hash)
448
+ let st
449
+ try {
450
+ st = await fs.promises.stat(storePath)
451
+ } catch (e) {
452
+ this.registry.removeBlob(hash)
453
+ return { status: "gone" }
454
+ }
455
+ if (st.nlink > 1) return { status: "in-use" }
456
+ await fs.promises.unlink(storePath)
457
+ this.registry.removeBlob(hash)
458
+ await this.registry.appendEvent({ kind: "reclaim", hash, bytes_saved: st.size })
459
+ return { status: "reclaimed", bytes_freed: st.size }
460
+ }
461
+ // Startup registry verification (trigger 5): bounded by registry size,
462
+ // never a discovery walk. Prunes dead links, flags orphans, removes stray
463
+ // conversion tmp files, re-adopts blobs whose store name was deleted.
464
+ async verify() {
465
+ if (!this.enabled || !this.registry) return
466
+ for (const [linkPath, entry] of [...this.registry.links]) {
467
+ await fs.promises.unlink(linkPath + TMP_SUFFIX).catch(() => {})
468
+ let st = null
469
+ try {
470
+ st = await fs.promises.stat(linkPath)
471
+ } catch (e) {}
472
+ if (!st || st.ino !== entry.ino || st.dev !== entry.dev) {
473
+ this.registry.removeLink(linkPath)
474
+ }
475
+ }
476
+ for (const [dupPath] of [...this.registry.duplicates]) {
477
+ try {
478
+ await fs.promises.stat(dupPath)
479
+ } catch (e) {
480
+ this.registry.duplicates.delete(dupPath)
481
+ }
482
+ }
483
+ for (const [hash, blob] of [...this.registry.blobs]) {
484
+ const storePath = this.storePathFor(hash)
485
+ let st = null
486
+ try {
487
+ st = await fs.promises.stat(storePath)
488
+ } catch (e) {}
489
+ if (!st) {
490
+ // Store name gone; re-adopt from a surviving app name if one exists.
491
+ let readopted = false
492
+ for (const [linkPath, entry] of this.registry.links) {
493
+ if (entry.hash !== hash || entry.mode !== "link") continue
494
+ try {
495
+ await fs.promises.mkdir(path.dirname(storePath), { recursive: true })
496
+ await fs.promises.link(linkPath, storePath)
497
+ readopted = true
498
+ break
499
+ } catch (e) {}
500
+ }
501
+ if (!readopted) this.registry.removeBlob(hash)
502
+ continue
503
+ }
504
+ blob.orphan = st.nlink === 1
505
+ blob.verified_at = Date.now()
506
+ }
507
+ this.registry.schedulePersist()
508
+ }
509
+ // Rebuild derived state from disk: store filenames are hashes, stat gives
510
+ // nlink, and (dev, ino) matching re-associates app paths. The replacement
511
+ // maps are assembled off to the side and swapped in only after the walk so
512
+ // a long repair never persists a half-reset registry.
513
+ async rebuild(roots, options = {}) {
514
+ if (!this.enabled) return
515
+ const registry = this.registry
516
+ const preserveState = options.preserveState !== false
517
+ const preserved = preserveState ? {
518
+ excluded: new Map(registry.excluded),
519
+ totals: Object.assign({}, registry.totals),
520
+ lastScan: registry.lastScan ? Object.assign({}, registry.lastScan) : null,
521
+ duplicates: new Map(registry.duplicates)
522
+ } : null
523
+ const rebuiltBlobs = new Map()
524
+ const rebuiltLinks = new Map()
525
+ const storeInoToHash = new Map()
526
+ let shards = []
527
+ try {
528
+ shards = await fs.promises.readdir(this.blobRoot)
529
+ } catch (e) {}
530
+ for (const shard of shards) {
531
+ let names = []
532
+ try {
533
+ names = await fs.promises.readdir(path.resolve(this.blobRoot, shard))
534
+ } catch (e) { continue }
535
+ for (const name of names) {
536
+ if (!/^[0-9a-f]{64}$/.test(name)) continue
537
+ try {
538
+ const st = await fs.promises.stat(path.resolve(this.blobRoot, shard, name))
539
+ const previous = registry.blobs.get(name)
540
+ rebuiltBlobs.set(name, {
541
+ size: st.size,
542
+ first_seen: previous && previous.first_seen ? previous.first_seen : Date.now(),
543
+ source_urls: previous && Array.isArray(previous.source_urls) ? [...previous.source_urls] : [],
544
+ verified_at: Date.now(),
545
+ orphan: st.nlink === 1
546
+ })
547
+ storeInoToHash.set(`${st.dev}:${st.ino}`, name)
548
+ } catch (e) {}
549
+ }
550
+ }
551
+ await this.refreshSources()
552
+ const walkRoots = roots
553
+ ? roots.map((root) => typeof root === "string" ? { root, source_id: null } : root)
554
+ : this.scanRoots()
555
+ for (const entry of walkRoots) {
556
+ await this.walkForInoMatch(
557
+ entry.root,
558
+ storeInoToHash,
559
+ entry.source_id === "pinokio" ? null : entry.source_id,
560
+ rebuiltLinks
561
+ )
562
+ }
563
+ const rebuiltDuplicates = new Map()
564
+ if (preserved) {
565
+ for (const [duplicatePath, duplicate] of preserved.duplicates) {
566
+ if (preserved.excluded.has(duplicatePath) || rebuiltLinks.has(duplicatePath) || !rebuiltBlobs.has(duplicate.hash)) continue
567
+ try {
568
+ const st = await fs.promises.stat(duplicatePath)
569
+ if (st.isFile() && (!duplicate.size || duplicate.size === st.size)) {
570
+ rebuiltDuplicates.set(duplicatePath, Object.assign({}, duplicate, { size: st.size }))
571
+ }
572
+ } catch (error) {}
573
+ }
574
+ }
575
+ const rebuiltByIno = new Map()
576
+ for (const entry of rebuiltLinks.values()) {
577
+ rebuiltByIno.set(registry.inoKey(entry.dev, entry.ino), entry.hash)
578
+ }
579
+ registry.blobs = rebuiltBlobs
580
+ registry.links = rebuiltLinks
581
+ registry.scanIndex = new Map()
582
+ registry.duplicates = rebuiltDuplicates
583
+ registry.excluded = preserved ? preserved.excluded : new Map()
584
+ registry.lastScan = preserved ? preserved.lastScan : null
585
+ registry.totals = preserved ? preserved.totals : { lifetime_bytes_saved: 0 }
586
+ registry.byIno = rebuiltByIno
587
+ await registry.flush()
588
+ }
589
+ async walkForInoMatch(root, storeInoToHash, preferredSourceId = null, outputLinks = null) {
590
+ const stack = [root]
591
+ const vaultRoot = path.resolve(this.root)
592
+ while (stack.length) {
593
+ const dir = stack.pop()
594
+ let entries = []
595
+ try {
596
+ entries = await fs.promises.readdir(dir, { withFileTypes: true })
597
+ } catch (error) { continue }
598
+ const files = []
599
+ for (const entry of entries) {
600
+ const full = path.resolve(dir, entry.name)
601
+ if (entry.isSymbolicLink()) continue
602
+ if (entry.isDirectory()) {
603
+ if (full === vaultRoot) continue
604
+ stack.push(full)
605
+ }
606
+ else if (entry.isFile()) files.push(full)
607
+ }
608
+ const stats = await statMany(files, this.statConcurrency)
609
+ for (let index = 0; index < files.length; index++) {
610
+ const st = stats[index]
611
+ if (!st || st.size < this.sizeThreshold) continue
612
+ const hash = storeInoToHash.get(`${st.dev}:${st.ino}`)
613
+ if (hash) {
614
+ const source = this.sourceForPath(files[index], preferredSourceId)
615
+ const link = {
616
+ hash, app: source && source.kind === "app" ? source.app : null,
617
+ source_id: source ? source.id : null, dev: st.dev, ino: st.ino,
618
+ mode: "link", created: Date.now()
619
+ }
620
+ if (outputLinks) outputLinks.set(files[index], link)
621
+ else this.registry.addLink(files[index], link)
622
+ }
623
+ }
624
+ }
625
+ }
626
+ appNameFor(filePath) {
627
+ const source = this.sourceForPath(filePath)
628
+ return source && source.kind === "app" ? source.app : null
629
+ }
630
+ // Global state for the vault page and the health endpoint. Answered from
631
+ // memory + one stat per blob; never a walk.
632
+ async status() {
633
+ if (!this.enabled || !this.registry) return { enabled: false }
634
+ const registry = this.registry
635
+ const blobs = []
636
+ const blobByHash = new Map()
637
+ const storeStats = new Map()
638
+ const namesByHash = new Map()
639
+ for (const [linkPath, entry] of registry.links) {
640
+ if (!namesByHash.has(entry.hash)) namesByHash.set(entry.hash, [])
641
+ const location = this.locationForPath(linkPath, entry.source_id)
642
+ namesByHash.get(entry.hash).push(Object.assign({
643
+ path: linkPath, app: entry.app || null, mode: entry.mode
644
+ }, location))
645
+ }
646
+ let bytesOnDisk = 0
647
+ let wouldBe = 0
648
+ for (const [hash, blob] of registry.blobs) {
649
+ const names = namesByHash.get(hash) || []
650
+ const apps = new Set(names.map((name) => name.app).filter(Boolean))
651
+ let nlink = null
652
+ let storeStat = null
653
+ try {
654
+ storeStat = await fs.promises.stat(this.storePathFor(hash))
655
+ nlink = storeStat.nlink
656
+ } catch (e) {}
657
+ storeStats.set(hash, storeStat)
658
+ const size = blob.size || 0
659
+ const physicalCopies = Math.max(1, (storeStat ? 1 : 0) + names.filter((name) => name.mode === "copy").length)
660
+ bytesOnDisk += size * physicalCopies
661
+ wouldBe += size * Math.max(physicalCopies, names.length)
662
+ const publicBlob = {
663
+ hash, size: blob.size || 0, orphan: !!blob.orphan, nlink,
664
+ names, apps: [...apps], source_urls: blob.source_urls || []
665
+ }
666
+ blobs.push(publicBlob)
667
+ blobByHash.set(hash, publicBlob)
668
+ }
669
+ const duplicates = []
670
+ for (const [p, entry] of registry.duplicates) {
671
+ const location = this.locationForPath(p, entry.source_id)
672
+ const source = this.sourceForPath(p, location.source_id)
673
+ const index = registry.scanIndex.get(p)
674
+ const storeStat = storeStats.get(entry.hash)
675
+ const shareable = !!(source && source.shareable && storeStat && (!index || index.dev === undefined || index.dev === storeStat.dev))
676
+ const blob = blobByHash.get(entry.hash)
677
+ duplicates.push(Object.assign({
678
+ path: p, hash: entry.hash, size: entry.size || 0, app: entry.app || null,
679
+ shareable, matches: blob ? blob.names : []
680
+ }, location))
681
+ }
682
+ const events = (await registry.readEvents()).slice(-100).reverse()
683
+ const scan = this.scanStatus()
684
+ const excluded = []
685
+ for (const [p, meta] of registry.excluded) {
686
+ let size = Number(meta.size) || 0
687
+ if (!size) {
688
+ try { size = (await fs.promises.stat(p)).size } catch (error) {}
689
+ }
690
+ excluded.push(Object.assign({ path: p, ts: meta.ts || null, size }, this.locationForPath(p, meta.source_id)))
691
+ }
692
+ const publicSources = this._sources.map((source) => ({
693
+ id: source.id, kind: source.kind, label: source.label, root: source.root,
694
+ display_path: source.kind === "pinokio" ? source.root : (source.mount_path || source.root),
695
+ target_path: source.kind === "external" ? source.root : null,
696
+ parent_id: source.parent_id, app: source.app || null,
697
+ available: source.available !== false, shareable: source.kind === "virtual" ? null : !!source.shareable
698
+ }))
699
+ return {
700
+ enabled: true,
701
+ mode: this.mode,
702
+ scan,
703
+ last_scan: registry.lastScan,
704
+ bytes_on_disk: bytesOnDisk,
705
+ bytes_without_sharing: wouldBe,
706
+ saved_by_sharing: wouldBe - bytesOnDisk,
707
+ lifetime_bytes_saved: registry.totals.lifetime_bytes_saved,
708
+ reclaimable: blobs.filter((b) => b.orphan).reduce((sum, b) => sum + b.size, 0),
709
+ pending_bytes: duplicates.filter((d) => d.shareable).reduce((sum, d) => sum + d.size, 0),
710
+ unavailable_bytes: duplicates.filter((d) => !d.shareable).reduce((sum, d) => sum + d.size, 0),
711
+ sources: publicSources, blobs, duplicates, excluded, events
712
+ }
713
+ }
714
+ scanStatus() {
715
+ const sweeper = this.sweeper
716
+ return sweeper ? Object.assign({}, sweeper.state, {
717
+ current_file: sweeper.currentHash ? path.basename(sweeper.currentHash.path) : null
718
+ }) : null
719
+ }
720
+ progressStatus() {
721
+ return {
722
+ enabled: !!this.enabled,
723
+ scan: this.scanStatus(),
724
+ last_scan: this.registry ? this.registry.lastScan : null
725
+ }
726
+ }
727
+ // detach: make one name an independent copy again ("go back"), and pin it
728
+ // so future scans neither re-list nor re-share it. Works on linked names
729
+ // (copies bytes out) and on pending duplicates (just ignores them).
730
+ async detach(filePath) {
731
+ if (!this.enabled) return { status: "disabled" }
732
+ const entry = this.registry.links.get(filePath)
733
+ if (entry) {
734
+ const tmp = filePath + TMP_SUFFIX
735
+ await fs.promises.copyFile(filePath, tmp)
736
+ await fs.promises.rename(tmp, filePath)
737
+ const st = await fs.promises.stat(filePath)
738
+ this.registry.removeLink(filePath)
739
+ this.registry.excluded.set(filePath, { ts: Date.now(), source_id: entry.source_id || null, size: st.size })
740
+ this.registry.schedulePersist()
741
+ await this.registry.appendEvent({ kind: "detach", hash: entry.hash, path: filePath, app: entry.app || null, source_id: entry.source_id || null, size: st.size })
742
+ return { status: "detached" }
743
+ }
744
+ if (this.registry.duplicates.has(filePath)) {
745
+ const dup = this.registry.duplicates.get(filePath)
746
+ this.registry.duplicates.delete(filePath)
747
+ this.registry.excluded.set(filePath, { ts: Date.now(), source_id: dup.source_id || null, size: dup.size || 0 })
748
+ this.registry.schedulePersist()
749
+ await this.registry.appendEvent({ kind: "detach", hash: dup.hash, path: filePath, app: dup.app || null, source_id: dup.source_id || null, size: dup.size || 0 })
750
+ return { status: "ignored" }
751
+ }
752
+ return { status: "not-found" }
753
+ }
754
+ // reshare: un-pin a detached path; the next scan may list it again.
755
+ async reshare(filePath) {
756
+ if (!this.enabled) return { status: "disabled" }
757
+ const excluded = this.registry.excluded.get(filePath)
758
+ if (this.registry.excluded.delete(filePath)) {
759
+ this.registry.schedulePersist()
760
+ await this.registry.appendEvent({ kind: "reshare", path: filePath, source_id: excluded && excluded.source_id ? excluded.source_id : null })
761
+ return { status: "resharable" }
762
+ }
763
+ return { status: "not-found" }
764
+ }
765
+ // Undo a conversion batch: replace each converted name with an independent
766
+ // copy of the bytes (spec, UX contract surface 3).
767
+ async undoBatch(batchId) {
768
+ if (!this.enabled || !batchId) return { undone: 0 }
769
+ const events = await this.registry.readEvents()
770
+ const summary = { undone: 0, bytes: 0 }
771
+ for (const event of events) {
772
+ if (event.kind !== "convert" || event.batch_id !== batchId || !event.path) continue
773
+ const entry = this.registry.links.get(event.path)
774
+ if (!entry || entry.hash !== event.hash) continue
775
+ const storePath = this.storePathFor(event.hash)
776
+ try {
777
+ const st = await fs.promises.stat(event.path)
778
+ const storeStat = await fs.promises.stat(storePath)
779
+ if (st.ino !== storeStat.ino || st.dev !== storeStat.dev) continue
780
+ const tmp = event.path + TMP_SUFFIX
781
+ await fs.promises.copyFile(storePath, tmp)
782
+ await fs.promises.rename(tmp, event.path)
783
+ this.registry.removeLink(event.path)
784
+ this.registry.duplicates.set(event.path, {
785
+ hash: event.hash, size: storeStat.size, app: entry.app || null,
786
+ source_id: entry.source_id || event.source_id || null, discovered: Date.now()
787
+ })
788
+ this.registry.totals.lifetime_bytes_saved = Math.max(0,
789
+ this.registry.totals.lifetime_bytes_saved - (event.bytes_saved || 0))
790
+ await this.registry.appendEvent({
791
+ kind: "undo", hash: event.hash, path: event.path,
792
+ source_id: entry.source_id || event.source_id || null, batch_id: batchId,
793
+ bytes_saved: event.bytes_saved || storeStat.size
794
+ })
795
+ summary.undone += 1
796
+ summary.bytes += storeStat.size
797
+ } catch (e) {}
798
+ }
799
+ this.registry.schedulePersist()
800
+ return summary
801
+ }
802
+ async reclaimAll() {
803
+ if (!this.enabled) return { reclaimed: 0, bytes_freed: 0 }
804
+ const summary = { reclaimed: 0, bytes_freed: 0 }
805
+ for (const [hash, blob] of [...this.registry.blobs]) {
806
+ if (!blob.orphan) continue
807
+ const result = await this.reclaim(hash)
808
+ if (result.status === "reclaimed") {
809
+ summary.reclaimed += 1
810
+ summary.bytes_freed += result.bytes_freed || 0
811
+ }
812
+ }
813
+ return summary
814
+ }
815
+ }
816
+
817
+ Vault.SIZE_THRESHOLD = SIZE_THRESHOLD
818
+ Vault.TMP_SUFFIX = TMP_SUFFIX
819
+
820
+ module.exports = Vault