pinokiod 8.0.44 → 8.0.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/kernel/vault/index.js +18 -2
- package/package.json +1 -1
- package/server/public/vault.css +2 -2
- package/server/public/vault.js +6 -5
- package/test/vault-engine.test.js +106 -0
- package/test/vault-ui.test.js +14 -7
package/kernel/vault/index.js
CHANGED
|
@@ -875,10 +875,26 @@ class Vault {
|
|
|
875
875
|
return { status: "no-blob" }
|
|
876
876
|
}
|
|
877
877
|
if (!storeStat) return { status: "no-blob" }
|
|
878
|
-
|
|
878
|
+
let targetStat = await fs.promises.lstat(targetPath)
|
|
879
879
|
if (!storeStat.isFile() || !targetStat.isFile()) return { status: "stale" }
|
|
880
880
|
if (meta.expected && !sameSnapshot(meta.expected, targetStat)) {
|
|
881
|
-
|
|
881
|
+
// Windows metadata operations can change ctime without changing the
|
|
882
|
+
// file's identity or bytes. Recover only that isolated mismatch by
|
|
883
|
+
// hashing the target again, then retain the exact pre-rename check below.
|
|
884
|
+
if (!sameContentState(meta.expected, targetStat)) return { status: "stale" }
|
|
885
|
+
const before = fileSnapshot(targetStat)
|
|
886
|
+
let verifiedTarget
|
|
887
|
+
try {
|
|
888
|
+
verifiedTarget = await this.hashFile(targetPath)
|
|
889
|
+
} catch (error) {
|
|
890
|
+
return { status: "stale" }
|
|
891
|
+
}
|
|
892
|
+
const after = await lstatIfPresent(targetPath)
|
|
893
|
+
if (!sameSnapshot(before, after) ||
|
|
894
|
+
verifiedTarget.hash !== hash || verifiedTarget.size !== after.size) {
|
|
895
|
+
return { status: "stale" }
|
|
896
|
+
}
|
|
897
|
+
targetStat = after
|
|
882
898
|
}
|
|
883
899
|
if (storeStat.dev !== targetStat.dev) {
|
|
884
900
|
return { status: "unavailable", code: "EXDEV" }
|
package/package.json
CHANGED
package/server/public/vault.css
CHANGED
|
@@ -234,13 +234,13 @@ body[data-vault-mode="app"] .vault-overview {
|
|
|
234
234
|
white-space: nowrap;
|
|
235
235
|
}
|
|
236
236
|
.vault-button:hover { background: var(--task-soft); }
|
|
237
|
-
.vault-button.
|
|
237
|
+
.vault-button.primary {
|
|
238
238
|
border-color: var(--task-accent-contrast);
|
|
239
239
|
background: var(--task-accent-contrast);
|
|
240
240
|
color: #101828;
|
|
241
241
|
font-weight: 650;
|
|
242
242
|
}
|
|
243
|
-
.vault-button.
|
|
243
|
+
.vault-button.primary:hover {
|
|
244
244
|
border-color: color-mix(in srgb, var(--task-accent-contrast) 88%, var(--task-panel));
|
|
245
245
|
background: color-mix(in srgb, var(--task-accent-contrast) 88%, var(--task-panel));
|
|
246
246
|
color: #101828;
|
package/server/public/vault.js
CHANGED
|
@@ -142,7 +142,6 @@ const COPY = {
|
|
|
142
142
|
files: "files",
|
|
143
143
|
location: "location",
|
|
144
144
|
deduplicate: "Deduplicate",
|
|
145
|
-
deduplicate_all: "Deduplicate all",
|
|
146
145
|
deduplicating: "Deduplicating files",
|
|
147
146
|
deduplicating_file: "Deduplicating file",
|
|
148
147
|
deduplication_progress: "{done} of {total} files",
|
|
@@ -468,8 +467,8 @@ const bulkDeduplicationAction = (items) => {
|
|
|
468
467
|
const candidates = bulkDeduplicationItems(items, selection)
|
|
469
468
|
if (!candidates.length) return ""
|
|
470
469
|
const context = state.sourceId || ""
|
|
471
|
-
const label = `${COPY.
|
|
472
|
-
return `<button class="vault-button" type="button" data-deduplicate-all="${selection}" data-deduplicate-context="${attr(context)}" aria-label="${attr(label)}" title="${attr(label)}">${esc(
|
|
470
|
+
const label = `${COPY.deduplicate} ${countLabel(candidates.length)}`
|
|
471
|
+
return `<button class="vault-button primary" type="button" data-deduplicate-all="${selection}" data-deduplicate-context="${attr(context)}" aria-label="${attr(label)}" title="${attr(label)}">${esc(label)}</button>`
|
|
473
472
|
}
|
|
474
473
|
const batchAction = (source) => {
|
|
475
474
|
if (!source || source.kind === "virtual" || source.kind === "pinokio") return ""
|
|
@@ -854,7 +853,7 @@ const renderOverview = () => {
|
|
|
854
853
|
const opportunity = activeScan
|
|
855
854
|
? `<span class="vault-summary-state"><i class="fa-solid fa-circle-notch fa-spin"></i>${esc(COPY.scanning)}</span>`
|
|
856
855
|
: pendingBytes
|
|
857
|
-
? `<span class="vault-summary-state attention"><i class="fa-regular fa-copy"></i><strong>${esc(COPY.more_can_be_saved.replace("{size}", fmt(pendingBytes)))}</strong></span
|
|
856
|
+
? `<span class="vault-summary-state attention"><i class="fa-regular fa-copy"></i><strong>${esc(COPY.more_can_be_saved.replace("{size}", fmt(pendingBytes)))}</strong></span>${state.view === "duplicates" ? "" : `<button class="vault-button${state.view === "independent" ? "" : " primary"}" type="button" id="btn-review-metric">${esc(COPY.review_files)}</button>`}`
|
|
858
857
|
: `<span class="vault-summary-state"><i class="fa-regular fa-circle-check"></i>${esc(COPY.nothing_more_to_save)}</span>`
|
|
859
858
|
const freshness = last ? `${COPY.scanned} ${timeAgo(last.ts)}` : COPY.not_scanned
|
|
860
859
|
metrics.innerHTML = `
|
|
@@ -994,7 +993,9 @@ const renderResult = () => {
|
|
|
994
993
|
}
|
|
995
994
|
const info = state.scanResult
|
|
996
995
|
const duplicateLabel = countLabel(info.count, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase())
|
|
997
|
-
const review = info.count
|
|
996
|
+
const review = info.count && state.view !== "duplicates"
|
|
997
|
+
? `<button class="vault-button${state.view === "independent" ? "" : " primary"}" type="button" id="btn-review-result">${esc(COPY.review)} ${duplicateLabel}<i class="fa-solid fa-chevron-right"></i></button>`
|
|
998
|
+
: ""
|
|
998
999
|
if (info.incomplete) {
|
|
999
1000
|
result.className = `vault-result show incomplete${state.scanProblemsOpen ? " expanded" : ""}`
|
|
1000
1001
|
const unreadableLabel = `${info.inaccessible} ${info.inaccessible === 1 ? COPY.scan_unreadable_path : COPY.scan_unreadable_paths}`
|
|
@@ -627,6 +627,112 @@ describe('vault engine (phase 1)', () => {
|
|
|
627
627
|
assert.deepStrictEqual(await fs.promises.readFile(pendingPath), changedContent)
|
|
628
628
|
})
|
|
629
629
|
|
|
630
|
+
test('deduplication rehashes a ctime-only snapshot mismatch before converting', async () => {
|
|
631
|
+
const h = await home()
|
|
632
|
+
const vault = await makeVault(h)
|
|
633
|
+
vault.sizeThreshold = 1
|
|
634
|
+
const content = crypto.randomBytes(4096)
|
|
635
|
+
await writeFile(path.resolve(h, 'api', 'appA', 'model.bin'), content)
|
|
636
|
+
await writeFile(path.resolve(h, 'api', 'appB', 'model.bin'), content)
|
|
637
|
+
await vault.sweeper.scan()
|
|
638
|
+
|
|
639
|
+
const [pendingPath, pendingEntry] = [...vault.registry.duplicates][0]
|
|
640
|
+
const indexed = vault.registry.scanIndex.get(pendingPath)
|
|
641
|
+
vault.registry.scanIndex.set(pendingPath, Object.assign({}, indexed, {
|
|
642
|
+
ctime: indexed.ctime - 1
|
|
643
|
+
}))
|
|
644
|
+
const realHashFile = vault.hashFile.bind(vault)
|
|
645
|
+
let targetRehashes = 0
|
|
646
|
+
vault.hashFile = async (filePath) => {
|
|
647
|
+
if (path.resolve(filePath) === pendingPath) targetRehashes += 1
|
|
648
|
+
return realHashFile(filePath)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
let result
|
|
652
|
+
try {
|
|
653
|
+
result = await vault.perform('deduplicate', { scope_id: pendingEntry.source_id })
|
|
654
|
+
} finally {
|
|
655
|
+
vault.hashFile = realHashFile
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
assert.strictEqual(targetRehashes, 1)
|
|
659
|
+
assert.strictEqual(result.converted, 1)
|
|
660
|
+
assert.strictEqual(result.stale, 0)
|
|
661
|
+
assert.strictEqual(vault.registry.duplicates.has(pendingPath), false)
|
|
662
|
+
assert.deepStrictEqual(await fs.promises.readFile(pendingPath), content)
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
test('ctime recovery never converts different bytes with matching identity, size, and mtime', async () => {
|
|
666
|
+
const h = await home()
|
|
667
|
+
const vault = await makeVault(h)
|
|
668
|
+
vault.sizeThreshold = 1
|
|
669
|
+
const originalContent = Buffer.from('AAAA')
|
|
670
|
+
const changedContent = Buffer.from('BBBB')
|
|
671
|
+
await writeFile(path.resolve(h, 'api', 'appA', 'model.bin'), originalContent)
|
|
672
|
+
await writeFile(path.resolve(h, 'api', 'appB', 'model.bin'), originalContent)
|
|
673
|
+
await vault.sweeper.scan()
|
|
674
|
+
|
|
675
|
+
const [pendingPath, pendingEntry] = [...vault.registry.duplicates][0]
|
|
676
|
+
const indexed = vault.registry.scanIndex.get(pendingPath)
|
|
677
|
+
await fs.promises.writeFile(pendingPath, changedContent)
|
|
678
|
+
const current = await fs.promises.lstat(pendingPath)
|
|
679
|
+
vault.registry.scanIndex.set(pendingPath, Object.assign({}, indexed, {
|
|
680
|
+
dev: current.dev,
|
|
681
|
+
ino: current.ino,
|
|
682
|
+
size: current.size,
|
|
683
|
+
mtime: current.mtimeMs,
|
|
684
|
+
ctime: current.ctimeMs - 1
|
|
685
|
+
}))
|
|
686
|
+
|
|
687
|
+
const result = await vault.perform('deduplicate', { scope_id: pendingEntry.source_id })
|
|
688
|
+
|
|
689
|
+
assert.strictEqual(result.converted, 0)
|
|
690
|
+
assert.strictEqual(result.stale, 1)
|
|
691
|
+
assert.strictEqual((await fs.promises.stat(pendingPath)).nlink, 1)
|
|
692
|
+
assert.ok(vault.registry.duplicates.has(pendingPath))
|
|
693
|
+
assert.deepStrictEqual(await fs.promises.readFile(pendingPath), changedContent)
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
test('ctime recovery never overwrites a file replaced during its verification hash', async () => {
|
|
697
|
+
const h = await home()
|
|
698
|
+
const vault = await makeVault(h)
|
|
699
|
+
vault.sizeThreshold = 1
|
|
700
|
+
const content = crypto.randomBytes(4096)
|
|
701
|
+
const replacement = crypto.randomBytes(4096)
|
|
702
|
+
await writeFile(path.resolve(h, 'api', 'appA', 'model.bin'), content)
|
|
703
|
+
await writeFile(path.resolve(h, 'api', 'appB', 'model.bin'), content)
|
|
704
|
+
await vault.sweeper.scan()
|
|
705
|
+
|
|
706
|
+
const [pendingPath, pendingEntry] = [...vault.registry.duplicates][0]
|
|
707
|
+
const indexed = vault.registry.scanIndex.get(pendingPath)
|
|
708
|
+
vault.registry.scanIndex.set(pendingPath, Object.assign({}, indexed, {
|
|
709
|
+
ctime: indexed.ctime - 1
|
|
710
|
+
}))
|
|
711
|
+
const realHashFile = vault.hashFile.bind(vault)
|
|
712
|
+
vault.hashFile = async (filePath) => {
|
|
713
|
+
const result = await realHashFile(filePath)
|
|
714
|
+
if (path.resolve(filePath) === pendingPath) {
|
|
715
|
+
const writerPath = `${pendingPath}.writer-replacement`
|
|
716
|
+
await fs.promises.writeFile(writerPath, replacement)
|
|
717
|
+
await fs.promises.rename(writerPath, pendingPath)
|
|
718
|
+
}
|
|
719
|
+
return result
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
let result
|
|
723
|
+
try {
|
|
724
|
+
result = await vault.perform('deduplicate', { scope_id: pendingEntry.source_id })
|
|
725
|
+
} finally {
|
|
726
|
+
vault.hashFile = realHashFile
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
assert.strictEqual(result.converted, 0)
|
|
730
|
+
assert.strictEqual(result.stale, 1)
|
|
731
|
+
assert.strictEqual((await fs.promises.stat(pendingPath)).nlink, 1)
|
|
732
|
+
assert.ok(vault.registry.duplicates.has(pendingPath))
|
|
733
|
+
assert.deepStrictEqual(await fs.promises.readFile(pendingPath), replacement)
|
|
734
|
+
})
|
|
735
|
+
|
|
630
736
|
test('deduplication revalidates canonical content before replacing a pending file', async () => {
|
|
631
737
|
const h = await home()
|
|
632
738
|
const vault = await makeVault(h)
|
package/test/vault-ui.test.js
CHANGED
|
@@ -993,8 +993,8 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
993
993
|
assert.match(vaultPage, /Number\(data\.bytes_on_disk\)/)
|
|
994
994
|
assert.match(vaultPage, /Number\(data\.pending_bytes\)/)
|
|
995
995
|
assert.match(vaultPage, /id="btn-review-metric"/)
|
|
996
|
-
assert.match(vaultPage, /\.vault-button\.
|
|
997
|
-
assert.match(vaultPage, /
|
|
996
|
+
assert.match(vaultPage, /\.vault-button\.primary \{[\s\S]*?background:\s*var\(--task-accent-contrast\)[\s\S]*?color:\s*#101828/)
|
|
997
|
+
assert.match(vaultPage, /id="btn-review-result"/)
|
|
998
998
|
assert.match(vaultPage, /class='vault-button' id='btn-scan'/)
|
|
999
999
|
assert.match(vaultPage, /id='vault-storage-details'/)
|
|
1000
1000
|
assert.match(vaultPage, /fmt\(data\.lifetime_bytes_saved\)/)
|
|
@@ -1316,7 +1316,7 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1316
1316
|
assert.strictEqual(dom.window.document.querySelector('.vault-compare-info').getAttribute('tabindex'), '0')
|
|
1317
1317
|
assert.match(dom.window.document.querySelector('.vault-summary-side').textContent, /13\.31 GB more can be saved/)
|
|
1318
1318
|
assert.strictEqual(dom.window.document.getElementById('btn-review-metric').textContent, 'Review files')
|
|
1319
|
-
assert.strictEqual(dom.window.document.getElementById('btn-review-metric').classList.contains('
|
|
1319
|
+
assert.strictEqual(dom.window.document.getElementById('btn-review-metric').classList.contains('primary'), true)
|
|
1320
1320
|
assert.strictEqual(dom.window.document.getElementById('btn-scan').classList.contains('primary'), false)
|
|
1321
1321
|
assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Pinokio folder\s*166\.22 GB/)
|
|
1322
1322
|
assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Saved by your actions\s*258\.77 GB/)
|
|
@@ -1670,15 +1670,18 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1670
1670
|
|
|
1671
1671
|
try {
|
|
1672
1672
|
assert.strictEqual(dom.window.document.querySelector('[data-deduplicate-all]'), null)
|
|
1673
|
+
assert.strictEqual(dom.window.document.getElementById('btn-review-metric').classList.contains('primary'), true)
|
|
1673
1674
|
dom.window.document.querySelector('[data-view="duplicates"]').click()
|
|
1674
1675
|
let bulk = dom.window.document.querySelector('[data-deduplicate-all="duplicates"]')
|
|
1675
|
-
assert.strictEqual(bulk.textContent, 'Deduplicate
|
|
1676
|
+
assert.strictEqual(bulk.textContent, 'Deduplicate 2 files')
|
|
1677
|
+
assert.strictEqual(bulk.classList.contains('primary'), true)
|
|
1676
1678
|
assert.strictEqual(bulk.dataset.deduplicateContext, 'app:appB')
|
|
1677
|
-
assert.strictEqual(bulk.getAttribute('aria-label'), 'Deduplicate
|
|
1679
|
+
assert.strictEqual(bulk.getAttribute('aria-label'), 'Deduplicate 2 files')
|
|
1680
|
+
assert.strictEqual(dom.window.document.getElementById('btn-review-metric'), null)
|
|
1678
1681
|
const search = dom.window.document.getElementById('vault-search')
|
|
1679
1682
|
search.value = 'one.bin'
|
|
1680
1683
|
search.dispatchEvent(new dom.window.Event('input', { bubbles: true }))
|
|
1681
|
-
assert.strictEqual(bulk.getAttribute('aria-label'), 'Deduplicate
|
|
1684
|
+
assert.strictEqual(bulk.getAttribute('aria-label'), 'Deduplicate 2 files',
|
|
1682
1685
|
'search never silently narrows the bulk action')
|
|
1683
1686
|
bulk.click()
|
|
1684
1687
|
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
@@ -1690,9 +1693,13 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1690
1693
|
|
|
1691
1694
|
dom.window.document.querySelector('[data-view="shared"]').click()
|
|
1692
1695
|
assert.strictEqual(dom.window.document.querySelector('[data-deduplicate-all]'), null)
|
|
1696
|
+
assert.strictEqual(dom.window.document.getElementById('btn-review-metric').classList.contains('primary'), true)
|
|
1693
1697
|
dom.window.document.querySelector('[data-view="independent"]').click()
|
|
1694
1698
|
bulk = dom.window.document.querySelector('[data-deduplicate-all="kept-separate"]')
|
|
1695
|
-
assert.strictEqual(bulk.
|
|
1699
|
+
assert.strictEqual(bulk.textContent, 'Deduplicate 2 files')
|
|
1700
|
+
assert.strictEqual(bulk.getAttribute('aria-label'), 'Deduplicate 2 files')
|
|
1701
|
+
assert.strictEqual(bulk.classList.contains('primary'), true)
|
|
1702
|
+
assert.strictEqual(dom.window.document.getElementById('btn-review-metric').classList.contains('primary'), false)
|
|
1696
1703
|
bulk.click()
|
|
1697
1704
|
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
1698
1705
|
const operation = dom.window.document.getElementById('vault-action-state')
|