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.
@@ -875,10 +875,26 @@ class Vault {
875
875
  return { status: "no-blob" }
876
876
  }
877
877
  if (!storeStat) return { status: "no-blob" }
878
- const targetStat = await fs.promises.lstat(targetPath)
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
- return { status: "stale" }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.44",
3
+ "version": "8.0.45",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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.review-primary {
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.review-primary:hover {
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;
@@ -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.deduplicate_all}: ${countLabel(candidates.length)}`
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(COPY.deduplicate_all)}</button>`
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><button class="vault-button review-primary" type="button" id="btn-review-metric">${esc(COPY.review_files)}</button>`
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 ? `<button class="vault-button review-primary" type="button" id="btn-review-result">${esc(COPY.review)} ${duplicateLabel}<i class="fa-solid fa-chevron-right"></i></button>` : ""
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)
@@ -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\.review-primary \{[\s\S]*?background:\s*var\(--task-accent-contrast\)[\s\S]*?color:\s*#101828/)
997
- assert.match(vaultPage, /class="vault-button review-primary" type="button" id="btn-review-result"/)
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('review-primary'), true)
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 all')
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 all: 2 files')
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 all: 2 files',
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.getAttribute('aria-label'), 'Deduplicate all: 2 files')
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')