dsh-config-manager 0.2.3 → 0.3.0
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/lib/adapters/self.d.ts +44 -4
- package/lib/adapters/self.js +63 -3
- package/lib/client.d.ts +11 -6
- package/lib/client.js +1256 -1185
- package/lib/core/analyzer.d.ts +3 -0
- package/lib/core/analyzer.js +32 -56
- package/lib/core/backup.d.ts +36 -0
- package/lib/core/backup.js +125 -3
- package/lib/core/import-defaults.d.ts +13 -0
- package/lib/core/import-defaults.js +14 -0
- package/lib/core/importer.d.ts +10 -1
- package/lib/core/importer.js +7 -1
- package/lib/core/messages.d.ts +4 -0
- package/lib/core/messages.js +8 -0
- package/lib/core/model-tools.js +19 -3
- package/lib/index.d.ts +1 -1
- package/lib/index.js +12 -22
- package/lib/routes/import.js +3 -1
- package/lib/routes/recovery.js +226 -2
- package/lib/routes/sync.js +26 -7
- package/lib/security/integrity.d.ts +24 -0
- package/lib/security/integrity.js +30 -0
- package/lib/security/unverified-file-blocks.d.ts +73 -0
- package/lib/security/unverified-file-blocks.js +151 -0
- package/lib/sync/autosync-config.d.ts +8 -4
- package/lib/sync/autosync-config.js +2 -2
- package/lib/sync/autosync-scheduler.d.ts +61 -3
- package/lib/sync/autosync-scheduler.js +87 -10
- package/lib/sync/config-issue-reporting.d.ts +32 -0
- package/lib/sync/config-issue-reporting.js +63 -0
- package/lib/sync/gc-skip.d.ts +42 -0
- package/lib/sync/gc-skip.js +57 -0
- package/lib/sync/git/git-transport.d.ts +56 -2
- package/lib/sync/git/git-transport.js +344 -73
- package/lib/sync/merge-preview-text.d.ts +11 -0
- package/lib/sync/merge-preview-text.js +38 -0
- package/lib/sync/s3/s3-transport.d.ts +8 -1
- package/lib/sync/s3/s3-transport.js +19 -5
- package/lib/sync/session-tombstones.d.ts +53 -0
- package/lib/sync/session-tombstones.js +52 -0
- package/lib/sync/sync-config.d.ts +55 -5
- package/lib/sync/sync-config.js +188 -15
- package/lib/sync/sync-engine.d.ts +21 -7
- package/lib/sync/sync-engine.js +63 -85
- package/lib/sync/webdav/webdav-transport.d.ts +11 -1
- package/lib/sync/webdav/webdav-transport.js +22 -6
- package/lib/ui/i18n.js +56 -36
- package/lib/ui/import-wizard.d.ts +1 -1
- package/lib/ui/import-wizard.js +2 -1
- package/lib/ui/overview-view.d.ts +6 -2
- package/lib/ui/overview-view.js +8 -1
- package/lib/ui/report.js +2 -1
- package/lib/utils/git-fast-forward.d.ts +7 -2
- package/lib/utils/git-fast-forward.js +8 -3
- package/package.json +2 -2
- package/src/adapters/self.test.ts +126 -85
- package/src/adapters/self.ts +73 -6
- package/src/client/ConfigManagerSection.tsx +0 -3
- package/src/client/common/Icon.tsx +3 -0
- package/src/client/common/SectionComposition.tsx +6 -1
- package/src/client/common/info-hint-guard.test.ts +7 -5
- package/src/client/config-manager.module.css +254 -13
- package/src/client/environment/EnvironmentPanel.tsx +57 -47
- package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
- package/src/client/export/ExportView.tsx +3 -1
- package/src/client/home/HomePanel.tsx +25 -5
- package/src/client/import/ImportWizardView.tsx +1 -3
- package/src/client/import/import-wizard-steps.tsx +1 -17
- package/src/client/library/ArtifactRow.tsx +13 -2
- package/src/client/library/LibraryPanel.tsx +12 -2
- package/src/client/locales.ts +53 -34
- package/src/client/lucide-icons.d.ts +2 -0
- package/src/client/market/MarketPanel.tsx +12 -11
- package/src/client/market/market-locales.ts +16 -16
- package/src/client/recovery/RollbackCard.tsx +4 -2
- package/src/client/recovery/recovery-locales.test.ts +4 -2
- package/src/client/recovery/recovery-locales.ts +23 -23
- package/src/client/sync/AutosyncCard.tsx +36 -10
- package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
- package/src/client/sync/SyncPanel.tsx +32 -33
- package/src/client/sync/SyncProbeRow.tsx +23 -6
- package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
- package/src/client/sync/sync-locales.ts +24 -24
- package/src/core/analyzer.ts +37 -56
- package/src/core/backup.test.ts +139 -1
- package/src/core/backup.ts +145 -4
- package/src/core/import-defaults.ts +13 -0
- package/src/core/importer.ts +12 -2
- package/src/core/integrity-extra-entries.test.ts +205 -0
- package/src/core/messages.test.ts +3 -1
- package/src/core/messages.ts +8 -0
- package/src/core/model-tools.test.ts +33 -0
- package/src/core/model-tools.ts +20 -4
- package/src/index.ts +12 -21
- package/src/routes/import.ts +4 -1
- package/src/routes/recovery.test.ts +198 -1
- package/src/routes/recovery.ts +194 -1
- package/src/routes/sync.ts +27 -8
- package/src/security/integrity.ts +37 -0
- package/src/security/unverified-file-blocks.ts +192 -0
- package/src/sync/autosync-config.ts +9 -5
- package/src/sync/autosync-scheduler.test.ts +133 -6
- package/src/sync/autosync-scheduler.ts +118 -14
- package/src/sync/config-issue-reporting.test.ts +148 -0
- package/src/sync/config-issue-reporting.ts +75 -0
- package/src/sync/gc-skip.test.ts +66 -0
- package/src/sync/gc-skip.ts +64 -0
- package/src/sync/git/git-probe.test.ts +6 -1
- package/src/sync/git/git-transport.test.ts +351 -14
- package/src/sync/git/git-transport.ts +343 -79
- package/src/sync/merge-preview-text.ts +37 -0
- package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
- package/src/sync/s3/s3-transport.ts +23 -4
- package/src/sync/session-tombstones.ts +90 -0
- package/src/sync/sync-config.test.ts +170 -1
- package/src/sync/sync-config.ts +184 -14
- package/src/sync/sync-engine.test.ts +238 -0
- package/src/sync/sync-engine.ts +79 -83
- package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
- package/src/sync/webdav/webdav-transport.ts +26 -5
- package/src/ui/disk-usage-view.test.ts +2 -1
- package/src/ui/i18n.test.ts +47 -0
- package/src/ui/i18n.ts +58 -38
- package/src/ui/import-wizard.test.ts +18 -1
- package/src/ui/import-wizard.ts +3 -2
- package/src/ui/overview-view.test.ts +9 -0
- package/src/ui/overview-view.ts +9 -2
- package/src/ui/report.test.ts +4 -3
- package/src/ui/report.ts +2 -1
- package/src/utils/git-fast-forward.ts +8 -3
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import test from 'node:test';
|
|
14
14
|
import assert from 'node:assert/strict';
|
|
15
15
|
import http from 'node:http';
|
|
16
|
-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
17
|
import { utimesSync } from 'node:fs';
|
|
18
18
|
import { tmpdir } from 'node:os';
|
|
19
19
|
import { join } from 'node:path';
|
|
@@ -22,6 +22,7 @@ import type { Server } from 'node:http';
|
|
|
22
22
|
import { recoveryRoutes } from './recovery.ts';
|
|
23
23
|
import { routeSpecOf, type WebRoute } from './kit.ts';
|
|
24
24
|
import { DshProfileManager } from '../profiles/dsh-profile-manager.ts';
|
|
25
|
+
import { parseZip } from '../utils/zip.ts';
|
|
25
26
|
|
|
26
27
|
const BASE = '/api/dsh-config-manager/recovery';
|
|
27
28
|
|
|
@@ -569,3 +570,199 @@ test('t8 源码级:判据取自 preview 的 session.active,路由**不自己
|
|
|
569
570
|
assert.match(source, /rewindTouchesWorkspace\(rewindInput\)/, '是否要动工作区必须有唯一判据');
|
|
570
571
|
assert.equal((source.match(/sessionActiveGateApplies\(/g) ?? []).length, 3, '判定链位置只有一份判据(定义 + R1 + R2)');
|
|
571
572
|
});
|
|
573
|
+
|
|
574
|
+
/* ------------------------------------------------------------------ BK-01
|
|
575
|
+
* 反悔副本(pre-restore)只读出口:恢复覆盖前的原文落在 <snapshotDir>/pre-restore/<attempt>/
|
|
576
|
+
* (src/core/restore.ts 的 createPreRestoreAttemptDir / copyToPreRestore,命名 `NNNN-<label>`),
|
|
577
|
+
* 但此前 src/client、src/ui 零引用 ⇒ 用户既看不见也取不出来。这里只做两件只读的事:
|
|
578
|
+
* ① GET /recovery/pre-restore → 列出每个快照下的 attempt 目录与文件清单;
|
|
579
|
+
* ② POST /recovery/pre-restore/export → 把某个 attempt 目录打成 zip,落到用户指定目录。
|
|
580
|
+
* 硬约束:整条链路**不写快照目录一个字节**;zip 只写「用户显式指定的导出目录」,
|
|
581
|
+
* 且目标已存在 / 目标落在源目录内一律拒绝(禁止就地覆盖任何源文件)。
|
|
582
|
+
*/
|
|
583
|
+
|
|
584
|
+
interface PreRestoreFixture { root: string; out: string; cleanup: () => void }
|
|
585
|
+
|
|
586
|
+
/** 造一个快照根:两个带反悔副本的快照 + 一个没有 pre-restore 的快照。 */
|
|
587
|
+
function preRestoreFixture(): PreRestoreFixture {
|
|
588
|
+
const root = mkdtempSync(join(tmpdir(), 'dcm-bk01-snaps-'));
|
|
589
|
+
const out = mkdtempSync(join(tmpdir(), 'dcm-bk01-out-'));
|
|
590
|
+
const a = join(root, 'snap-a', 'pre-restore', '20261005-101010-123');
|
|
591
|
+
mkdirSync(a, { recursive: true });
|
|
592
|
+
writeFileSync(join(a, '0001-settings.yaml'), 'old-settings');
|
|
593
|
+
writeFileSync(join(a, '0002-sessions_x.jsonl'), 'old-session-line');
|
|
594
|
+
const b = join(root, 'snap-b', 'pre-restore', '20261006-090000-500');
|
|
595
|
+
mkdirSync(b, { recursive: true });
|
|
596
|
+
writeFileSync(join(b, '0001-plugins.yaml'), 'old-plugins');
|
|
597
|
+
mkdirSync(join(root, 'snap-c'), { recursive: true }); // 没有 pre-restore
|
|
598
|
+
return { root, out, cleanup: () => { rmSync(root, { recursive: true, force: true }); rmSync(out, { recursive: true, force: true }); } };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function envWithPreRestore(root: string, overrides: Record<string, unknown> = {}): unknown {
|
|
602
|
+
return {
|
|
603
|
+
dataDir: '/tmp/dcm-bk01-data',
|
|
604
|
+
snapshotsDir: root,
|
|
605
|
+
host: { homeDir: '/tmp/dcm-bk01-home', profile: 'web', log: { warn: () => {} } },
|
|
606
|
+
profiles: { listIncompleteCopies: () => [] },
|
|
607
|
+
recoveryOrchestrator: { status: async () => ({ status: 200, body: { incidents: [], running: [] } }) },
|
|
608
|
+
sessionHealth: {},
|
|
609
|
+
makeRecoveryExecutors: () => ({}),
|
|
610
|
+
tryAppendHistory: async () => undefined,
|
|
611
|
+
...overrides,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
test('BK-01/①:GET /recovery/pre-restore 列出每个快照下的 attempt 目录与文件清单(相对路径/大小/mtime)', async () => {
|
|
616
|
+
const f = preRestoreFixture();
|
|
617
|
+
try {
|
|
618
|
+
const route = routeOf(envWithPreRestore(f.root));
|
|
619
|
+
await withServer(route, async (base) => {
|
|
620
|
+
const res = await fetch(base + '/pre-restore');
|
|
621
|
+
assert.equal(res.status, 200);
|
|
622
|
+
const body = await res.json() as {
|
|
623
|
+
snapshots?: { snapshotId: string; attempts: { attempt: string; files: { relativePath: string; sizeBytes: number; mtimeMs: number }[] }[] }[];
|
|
624
|
+
unreadable?: unknown;
|
|
625
|
+
};
|
|
626
|
+
const ids = (body.snapshots ?? []).map((s) => s.snapshotId);
|
|
627
|
+
assert.deepEqual(ids.sort(), ['snap-a', 'snap-b'], '没有反悔副本的快照不必出现,但也不能凭空造');
|
|
628
|
+
const snapA = body.snapshots!.find((s) => s.snapshotId === 'snap-a')!;
|
|
629
|
+
assert.equal(snapA.attempts.length, 1, '同一快照下每次恢复一个新 attempt 目录');
|
|
630
|
+
assert.equal(snapA.attempts[0]!.attempt, '20261005-101010-123');
|
|
631
|
+
const files = snapA.attempts[0]!.files.map((x) => x.relativePath).sort();
|
|
632
|
+
assert.deepEqual(files, ['0001-settings.yaml', '0002-sessions_x.jsonl'], '文件清单按 attempt 目录内的相对路径给出');
|
|
633
|
+
const first = snapA.attempts[0]!.files.find((x) => x.relativePath === '0001-settings.yaml')!;
|
|
634
|
+
assert.equal(first.sizeBytes, 'old-settings'.length, '大小如实(反悔副本是原文)');
|
|
635
|
+
assert.equal(typeof first.mtimeMs, 'number');
|
|
636
|
+
assert.equal(body.unreadable, null, '读得动就必须如实说「没读不动」');
|
|
637
|
+
});
|
|
638
|
+
} finally {
|
|
639
|
+
f.cleanup();
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test('BK-01/②:POST /recovery/pre-restore/export 把某个 attempt 打成 zip 落到指定目录,源目录字节不变', async () => {
|
|
644
|
+
const f = preRestoreFixture();
|
|
645
|
+
try {
|
|
646
|
+
const src = join(f.root, 'snap-a', 'pre-restore', '20261005-101010-123');
|
|
647
|
+
const before = { a: readFileSync(join(src, '0001-settings.yaml'), 'utf8'), b: readFileSync(join(src, '0002-sessions_x.jsonl'), 'utf8') };
|
|
648
|
+
const route = routeOf(envWithPreRestore(f.root));
|
|
649
|
+
await withServer(route, async (base) => {
|
|
650
|
+
const res = await fetch(base + '/pre-restore/export', {
|
|
651
|
+
method: 'POST',
|
|
652
|
+
headers: { 'content-type': 'application/json' },
|
|
653
|
+
body: JSON.stringify({ snapshotId: 'snap-a', attempt: '20261005-101010-123', outputDir: f.out }),
|
|
654
|
+
});
|
|
655
|
+
const body = await res.json() as { ok?: boolean; zipPath?: string; entries?: number; code?: string };
|
|
656
|
+
assert.equal(res.status, 200, JSON.stringify(body));
|
|
657
|
+
assert.equal(body.ok, true);
|
|
658
|
+
const zipPath = body.zipPath!;
|
|
659
|
+
assert.equal(existsSync(zipPath), true, 'zip 必须真的落盘');
|
|
660
|
+
assert.equal(join(zipPath).startsWith(f.out), true, '只许写进用户指定的导出目录');
|
|
661
|
+
assert.equal(body.entries, 2);
|
|
662
|
+
const entries = parseZip(readFileSync(zipPath)).entries().map((e) => e.name).sort();
|
|
663
|
+
assert.deepEqual(entries, ['0001-settings.yaml', '0002-sessions_x.jsonl'], 'zip 里是 attempt 目录内的相对路径');
|
|
664
|
+
});
|
|
665
|
+
assert.equal(readFileSync(join(src, '0001-settings.yaml'), 'utf8'), before.a, '源文件字节不得变化');
|
|
666
|
+
assert.equal(readFileSync(join(src, '0002-sessions_x.jsonl'), 'utf8'), before.b);
|
|
667
|
+
assert.deepEqual(readdirSync(src).sort(), ['0001-settings.yaml', '0002-sessions_x.jsonl'], '不得在源目录里留任何产物');
|
|
668
|
+
} finally {
|
|
669
|
+
f.cleanup();
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test('BK-01/③:snapshotId / attempt 带穿越(../、绝对路径)→ 400,零写入', async () => {
|
|
674
|
+
const f = preRestoreFixture();
|
|
675
|
+
try {
|
|
676
|
+
const route = routeOf(envWithPreRestore(f.root));
|
|
677
|
+
await withServer(route, async (base) => {
|
|
678
|
+
for (const body of [
|
|
679
|
+
{ snapshotId: '../etc', attempt: '20261005-101010-123', outputDir: f.out },
|
|
680
|
+
{ snapshotId: 'snap-a', attempt: '../snap-b', outputDir: f.out },
|
|
681
|
+
{ snapshotId: 'snap-a', attempt: '20261005-101010-123', outputDir: '../escape' },
|
|
682
|
+
]) {
|
|
683
|
+
const res = await fetch(base + '/pre-restore/export', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
684
|
+
assert.equal(res.status, 400, JSON.stringify(body));
|
|
685
|
+
}
|
|
686
|
+
assert.deepEqual(readdirSync(f.out), [], '拒绝路径不得留下 zip');
|
|
687
|
+
});
|
|
688
|
+
} finally {
|
|
689
|
+
f.cleanup();
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test('BK-01/④:导出目录不存在 / attempt 不存在 → 400 / 404,且绝不静默成功', async () => {
|
|
694
|
+
const f = preRestoreFixture();
|
|
695
|
+
try {
|
|
696
|
+
const route = routeOf(envWithPreRestore(f.root));
|
|
697
|
+
await withServer(route, async (base) => {
|
|
698
|
+
const missingDir = await fetch(base + '/pre-restore/export', {
|
|
699
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
700
|
+
body: JSON.stringify({ snapshotId: 'snap-a', attempt: '20261005-101010-123', outputDir: join(f.out, 'nope') }),
|
|
701
|
+
});
|
|
702
|
+
assert.equal(missingDir.status, 400);
|
|
703
|
+
const missingAttempt = await fetch(base + '/pre-restore/export', {
|
|
704
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
705
|
+
body: JSON.stringify({ snapshotId: 'snap-a', attempt: '20990101-000000-000', outputDir: f.out }),
|
|
706
|
+
});
|
|
707
|
+
assert.equal(missingAttempt.status, 404);
|
|
708
|
+
assert.deepEqual(readdirSync(f.out), [], '两种拒绝都不得留下产物');
|
|
709
|
+
});
|
|
710
|
+
} finally {
|
|
711
|
+
f.cleanup();
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test('BK-01/⑤:目标 zip 已存在 → 409,绝不就地覆盖既有文件', async () => {
|
|
716
|
+
const f = preRestoreFixture();
|
|
717
|
+
try {
|
|
718
|
+
const route = routeOf(envWithPreRestore(f.root));
|
|
719
|
+
await withServer(route, async (base) => {
|
|
720
|
+
const body = JSON.stringify({ snapshotId: 'snap-a', attempt: '20261005-101010-123', outputDir: f.out });
|
|
721
|
+
const first = await fetch(base + '/pre-restore/export', { method: 'POST', headers: { 'content-type': 'application/json' }, body });
|
|
722
|
+
assert.equal(first.status, 200);
|
|
723
|
+
const zipPath = (await first.json() as { zipPath: string }).zipPath;
|
|
724
|
+
writeFileSync(zipPath, 'user-owned-bytes');
|
|
725
|
+
const second = await fetch(base + '/pre-restore/export', { method: 'POST', headers: { 'content-type': 'application/json' }, body });
|
|
726
|
+
assert.equal(second.status, 409);
|
|
727
|
+
assert.equal(readFileSync(zipPath, 'utf8'), 'user-owned-bytes', '已存在的目标文件必须原样保留');
|
|
728
|
+
});
|
|
729
|
+
} finally {
|
|
730
|
+
f.cleanup();
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
test('BK-01/⑥:导出目录落在 attempt 目录内 → 400,禁止往源目录里写', async () => {
|
|
735
|
+
const f = preRestoreFixture();
|
|
736
|
+
try {
|
|
737
|
+
const src = join(f.root, 'snap-a', 'pre-restore', '20261005-101010-123');
|
|
738
|
+
const route = routeOf(envWithPreRestore(f.root));
|
|
739
|
+
await withServer(route, async (base) => {
|
|
740
|
+
const res = await fetch(base + '/pre-restore/export', {
|
|
741
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
742
|
+
body: JSON.stringify({ snapshotId: 'snap-a', attempt: '20261005-101010-123', outputDir: src }),
|
|
743
|
+
});
|
|
744
|
+
assert.equal(res.status, 400);
|
|
745
|
+
assert.deepEqual(readdirSync(src).sort(), ['0001-settings.yaml', '0002-sessions_x.jsonl'], '源目录里一个字节都不许新增');
|
|
746
|
+
});
|
|
747
|
+
} finally {
|
|
748
|
+
f.cleanup();
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test('BK-01/⑦:快照根本身读不动 → 200 + readOnly + unreadable,不假装「没有反悔副本」', async () => {
|
|
753
|
+
const f = preRestoreFixture();
|
|
754
|
+
try {
|
|
755
|
+
// 指向一个不存在的根:readdir 失败 ⇒ 必须如实外露原因("没有" 与 "读不到" 不能混为一谈)
|
|
756
|
+
const route = routeOf(envWithPreRestore(join(f.root, 'no-such-root')));
|
|
757
|
+
await withServer(route, async (base) => {
|
|
758
|
+
const res = await fetch(base + '/pre-restore');
|
|
759
|
+
assert.equal(res.status, 200, '读不动是「说不清」,不是 500');
|
|
760
|
+
const body = await res.json() as { readOnly?: boolean; snapshots?: unknown[]; unreadable?: unknown };
|
|
761
|
+
assert.equal(body.readOnly, true);
|
|
762
|
+
assert.deepEqual(body.snapshots, []);
|
|
763
|
+
assert.notEqual(body.unreadable, null, '读不动必须外露,不能返回空清单冒充「没有」');
|
|
764
|
+
});
|
|
765
|
+
} finally {
|
|
766
|
+
f.cleanup();
|
|
767
|
+
}
|
|
768
|
+
});
|
package/src/routes/recovery.ts
CHANGED
|
@@ -21,7 +21,11 @@ import { EnvironmentLockUnavailableError, runWithMutationLock } from '../utils/e
|
|
|
21
21
|
import { scanSessionHealth } from '../utils/session-health-scan.ts'
|
|
22
22
|
import { applySessionTitles } from '../core/session-meta.ts'
|
|
23
23
|
import { applySessionLogTitles } from '../core/session-title.ts'
|
|
24
|
-
import { join } from 'node:path'
|
|
24
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
25
|
+
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
26
|
+
import { isPathSafe, isSameOrChild } from '../utils/paths.ts'
|
|
27
|
+
import { writeZip, type ZipWriteEntry } from '../utils/zip.ts'
|
|
28
|
+
import { isValidSnapshotId } from '../core/restore.ts'
|
|
25
29
|
import {
|
|
26
30
|
applySessionRepair,
|
|
27
31
|
listSessionRepairs,
|
|
@@ -312,6 +316,160 @@ function incompleteCopiesOf(profiles: {
|
|
|
312
316
|
}
|
|
313
317
|
}
|
|
314
318
|
|
|
319
|
+
/* ------------------------------------------------------------------ BK-01
|
|
320
|
+
* 反悔副本(pre-restore)只读出口。
|
|
321
|
+
*
|
|
322
|
+
* 恢复链路在覆盖/删除任何文件前,会把当前原文复制到
|
|
323
|
+
* `<snapshotsDir>/<snapshotId>/pre-restore/<attempt>/`(src/core/restore.ts 的
|
|
324
|
+
* createPreRestoreAttemptDir / copyToPreRestore,文件名 `NNNN-<label>`)。
|
|
325
|
+
* BK-01:这份数据此前在 host 侧零出口 —— 用户既看不见、也取不出来,等于「存在但不可达」。
|
|
326
|
+
* 这里只做两件**零写入**的事:列清单(GET)与把某个 attempt 打成 zip 落到用户指定的导出目录(POST)。
|
|
327
|
+
*
|
|
328
|
+
* 硬边界:
|
|
329
|
+
* - 整条链路不写快照目录一个字节(源目录里连临时文件都不许留);
|
|
330
|
+
* - zip 只落用户显式给出的**已存在**目录,目标同名文件已存在即 409(绝不就地覆盖任何既有文件);
|
|
331
|
+
* - 导出目录落在源目录之内(或源目录落在导出目录之内)一律 400。
|
|
332
|
+
*/
|
|
333
|
+
const PRE_RESTORE_DIR = 'pre-restore'
|
|
334
|
+
/** 单次枚举的上限:attempt 数 / 文件数 / 递归深度 / 单次导出总字节。防一个坏目录把首屏拖垮。 */
|
|
335
|
+
const PRE_RESTORE_MAX_ATTEMPTS = 200
|
|
336
|
+
const PRE_RESTORE_MAX_FILES = 2000
|
|
337
|
+
const PRE_RESTORE_MAX_DEPTH = 8
|
|
338
|
+
const PRE_RESTORE_EXPORT_MAX_BYTES = 256 * 1024 * 1024
|
|
339
|
+
/** attempt 目录名 = 恢复时间戳 `YYYYMMDD-HHMMSS-mmm`(restore.ts 的 preRestoreStamp),可选 `-N` 去重后缀。 */
|
|
340
|
+
const PRE_RESTORE_ATTEMPT_RE = /^[0-9]{8}-[0-9]{6}-[0-9]{3}(-[0-9]+)?$/
|
|
341
|
+
|
|
342
|
+
interface PreRestoreFile { relativePath: string; sizeBytes: number; mtimeMs: number }
|
|
343
|
+
interface PreRestoreAttempt { attempt: string; files: PreRestoreFile[]; totalBytes: number; skippedLinks: number; truncated: boolean }
|
|
344
|
+
interface PreRestoreSnapshot { snapshotId: string; attempts: PreRestoreAttempt[]; unreadable?: string; truncated?: true }
|
|
345
|
+
interface PreRestoreListing { readOnly: true; snapshots: PreRestoreSnapshot[]; unreadable: string | null }
|
|
346
|
+
|
|
347
|
+
function isPreRestoreAttemptName(name: unknown): name is string {
|
|
348
|
+
return typeof name === 'string' && PRE_RESTORE_ATTEMPT_RE.test(name)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
interface PreRestoreWalk { files: PreRestoreFile[]; totalBytes: number; skippedLinks: number; truncated: boolean }
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 递归走一遍 attempt 目录,只**读元数据**(readdir + stat),不读文件内容。
|
|
355
|
+
* symlink 不跟随(只计数):反悔副本里出现链接本身就可疑,跟过去会把 home 外的东西带进 zip。
|
|
356
|
+
* 超深度 / 超文件数 / 超字节即 `truncated` —— 调用方必须把截断当成「没说全」,不能当成「就这么多」。
|
|
357
|
+
*/
|
|
358
|
+
async function walkPreRestore(dir: string, maxBytes?: number): Promise<PreRestoreWalk> {
|
|
359
|
+
const files: PreRestoreFile[] = []
|
|
360
|
+
let totalBytes = 0
|
|
361
|
+
let skippedLinks = 0
|
|
362
|
+
let truncated = false
|
|
363
|
+
const walk = async (current: string, rel: string, depth: number): Promise<void> => {
|
|
364
|
+
if (truncated) return
|
|
365
|
+
if (depth > PRE_RESTORE_MAX_DEPTH) { truncated = true; return }
|
|
366
|
+
const entries = await readdir(current, { withFileTypes: true }).catch(() => null)
|
|
367
|
+
if (entries === null) { truncated = true; return }
|
|
368
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
369
|
+
for (const entry of entries) {
|
|
370
|
+
if (truncated) return
|
|
371
|
+
const abs = join(current, entry.name)
|
|
372
|
+
const relativePath = rel === '' ? entry.name : rel + '/' + entry.name
|
|
373
|
+
if (entry.isSymbolicLink()) { skippedLinks += 1; continue }
|
|
374
|
+
if (entry.isDirectory()) { await walk(abs, relativePath, depth + 1); continue }
|
|
375
|
+
if (!entry.isFile()) { skippedLinks += 1; continue }
|
|
376
|
+
const info = await stat(abs).catch(() => null)
|
|
377
|
+
if (info === null || !info.isFile()) { skippedLinks += 1; continue }
|
|
378
|
+
if (files.length >= PRE_RESTORE_MAX_FILES) { truncated = true; return }
|
|
379
|
+
if (maxBytes !== undefined && totalBytes + info.size > maxBytes) { truncated = true; return }
|
|
380
|
+
files.push({ relativePath, sizeBytes: info.size, mtimeMs: info.mtimeMs })
|
|
381
|
+
totalBytes += info.size
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
await walk(dir, '', 0)
|
|
385
|
+
return { files, totalBytes, skippedLinks, truncated }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** 列出快照根下每个快照的 pre-restore attempt 与文件清单。只读;快照根读不动如实外露。 */
|
|
389
|
+
async function listPreRestore(snapshotsDir: string): Promise<PreRestoreListing> {
|
|
390
|
+
const roots = await readdir(snapshotsDir, { withFileTypes: true }).catch(() => null)
|
|
391
|
+
if (roots === null) {
|
|
392
|
+
// 与 listSnapshots 同口径:读不动就如实说「读不动」,不抛 500、也不假装「没有反悔副本」
|
|
393
|
+
return { readOnly: true, snapshots: [], unreadable: snapshotsDir }
|
|
394
|
+
}
|
|
395
|
+
const snapshots: PreRestoreSnapshot[] = []
|
|
396
|
+
const ids = roots.filter((e) => e.isDirectory()).map((e) => e.name).sort()
|
|
397
|
+
for (const snapshotId of ids) {
|
|
398
|
+
if (!isValidSnapshotId(snapshotId)) continue
|
|
399
|
+
const base = join(snapshotsDir, snapshotId, PRE_RESTORE_DIR)
|
|
400
|
+
const entries = await readdir(base, { withFileTypes: true }).catch((error: unknown) => error)
|
|
401
|
+
if (!Array.isArray(entries)) {
|
|
402
|
+
const code = (entries as { code?: unknown }).code
|
|
403
|
+
// ENOENT = 这个快照从未做过恢复(正常态,不列);其余读不动如实标注
|
|
404
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') continue
|
|
405
|
+
snapshots.push({ snapshotId, attempts: [], unreadable: entries instanceof Error ? entries.message : String(entries) })
|
|
406
|
+
continue
|
|
407
|
+
}
|
|
408
|
+
const attempts = entries.filter((e) => e.isDirectory() && isPreRestoreAttemptName(e.name)).map((e) => e.name)
|
|
409
|
+
if (attempts.length === 0) continue
|
|
410
|
+
attempts.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)) // 新 → 旧
|
|
411
|
+
const kept = attempts.slice(0, PRE_RESTORE_MAX_ATTEMPTS)
|
|
412
|
+
const list: PreRestoreAttempt[] = []
|
|
413
|
+
for (const attempt of kept) {
|
|
414
|
+
const walked = await walkPreRestore(join(base, attempt))
|
|
415
|
+
list.push({ attempt, ...walked })
|
|
416
|
+
}
|
|
417
|
+
snapshots.push({ snapshotId, attempts: list, ...(attempts.length > kept.length ? { truncated: true as const } : {}) })
|
|
418
|
+
}
|
|
419
|
+
return { readOnly: true, snapshots, unreadable: null }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
type PreRestoreExportResult =
|
|
423
|
+
| { ok: true; zipPath: string; entries: number; totalBytes: number; skippedLinks: number }
|
|
424
|
+
| { ok: false; status: number; code: string; detail?: string }
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* 把某个 attempt 目录打成 zip 落到用户指定目录。
|
|
428
|
+
* 码表(界面按 code 出文案,不靠状态码猜原因):
|
|
429
|
+
* invalid-input 400 / attempt-not-found 404 / export-dir-missing 400 /
|
|
430
|
+
* export-inside-source 400 / export-target-exists 409 / export-too-large 413
|
|
431
|
+
*/
|
|
432
|
+
async function exportPreRestoreZip(snapshotsDir: string, body: Record<string, unknown>): Promise<PreRestoreExportResult> {
|
|
433
|
+
const fail = (status: number, code: string, detail?: string): PreRestoreExportResult =>
|
|
434
|
+
({ ok: false, status, code, ...(detail !== undefined ? { detail } : {}) })
|
|
435
|
+
const { snapshotId, attempt, outputDir } = body
|
|
436
|
+
if (!isValidSnapshotId(snapshotId)) return fail(400, 'invalid-input', 'snapshotId')
|
|
437
|
+
if (!isPreRestoreAttemptName(attempt)) return fail(400, 'invalid-input', 'attempt')
|
|
438
|
+
// 导出目录必须是**绝对路径**且不带上跳段:相对路径会按宿主进程 cwd 解析,用户根本不知道写到哪去了
|
|
439
|
+
if (typeof outputDir !== 'string' || outputDir.trim() === '' || !isAbsolute(outputDir) || outputDir.split(/[\\/]+/).includes('..')) {
|
|
440
|
+
return fail(400, 'invalid-input', 'outputDir')
|
|
441
|
+
}
|
|
442
|
+
const outDir = resolve(outputDir)
|
|
443
|
+
const attemptDir = resolve(join(snapshotsDir, snapshotId, PRE_RESTORE_DIR, attempt))
|
|
444
|
+
// 双保险:id / attempt 已过白名单,再按解析后的路径确认没跳出快照根
|
|
445
|
+
if (!isSameOrChild(attemptDir, resolve(snapshotsDir))) return fail(400, 'invalid-input', 'attempt')
|
|
446
|
+
const srcInfo = await stat(attemptDir).catch(() => null)
|
|
447
|
+
if (srcInfo === null || !srcInfo.isDirectory()) return fail(404, 'attempt-not-found')
|
|
448
|
+
// 导出目录必须已存在:不替用户造目录(造出来的目录没人认,还可能落在错的盘上)
|
|
449
|
+
const outInfo = await stat(outDir).catch(() => null)
|
|
450
|
+
if (outInfo === null || !outInfo.isDirectory()) return fail(400, 'export-dir-missing')
|
|
451
|
+
// 双向 containment 都是「往源里写」:导出目录在 attempt 内 / attempt 在导出目录内(含快照根本身)
|
|
452
|
+
if (isSameOrChild(outDir, attemptDir) || isSameOrChild(attemptDir, outDir)) return fail(400, 'export-inside-source')
|
|
453
|
+
const zipPath = join(outDir, `pre-restore-${snapshotId}-${attempt}.zip`)
|
|
454
|
+
const walked = await walkPreRestore(attemptDir, PRE_RESTORE_EXPORT_MAX_BYTES)
|
|
455
|
+
if (walked.truncated) return fail(413, 'export-too-large')
|
|
456
|
+
// 目标已存在 ⇒ 409:writeZip 内部会 rename 覆盖,绝不能让「导出」顺手抹掉用户已有的文件
|
|
457
|
+
if ((await stat(zipPath).catch(() => null)) !== null) return fail(409, 'export-target-exists')
|
|
458
|
+
const entries: ZipWriteEntry[] = []
|
|
459
|
+
for (const file of walked.files) {
|
|
460
|
+
if (!isPathSafe(file.relativePath)) return fail(400, 'invalid-input', 'entry')
|
|
461
|
+
const data = await readFile(join(attemptDir, file.relativePath)).catch(() => null)
|
|
462
|
+
if (data === null) return fail(500, 'read-failed', file.relativePath)
|
|
463
|
+
entries.push({ name: file.relativePath, data: new Uint8Array(data) })
|
|
464
|
+
}
|
|
465
|
+
try {
|
|
466
|
+
await writeZip(zipPath, entries, { maxEntries: PRE_RESTORE_MAX_FILES })
|
|
467
|
+
} catch (error) {
|
|
468
|
+
return fail(500, 'zip-write-failed', error instanceof Error ? error.message : String(error))
|
|
469
|
+
}
|
|
470
|
+
return { ok: true, zipPath, entries: entries.length, totalBytes: walked.totalBytes, skippedLinks: walked.skippedLinks }
|
|
471
|
+
}
|
|
472
|
+
|
|
315
473
|
export function recoveryRoutes(env: RoutesEnv): WebRoute[] {
|
|
316
474
|
const {
|
|
317
475
|
dataDir,
|
|
@@ -320,6 +478,7 @@ export function recoveryRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
320
478
|
profiles,
|
|
321
479
|
recoveryOrchestrator,
|
|
322
480
|
sessionHealth,
|
|
481
|
+
snapshotsDir,
|
|
323
482
|
tryAppendHistory,
|
|
324
483
|
} = env
|
|
325
484
|
return [
|
|
@@ -521,6 +680,40 @@ export function recoveryRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
521
680
|
}
|
|
522
681
|
return
|
|
523
682
|
}
|
|
683
|
+
// ------------------------------- BK-01:反悔副本(pre-restore)只读出口
|
|
684
|
+
// 挂在既有 recovery prefix 内,按 segments 分发 —— **不新增 endpoint 注册点**(本族本就是 prefix
|
|
685
|
+
// 路由,路由总数仍是 83;窗口守卫钉住那 7 条不受影响)。
|
|
686
|
+
// 零写入:GET 只读元数据;POST 只往用户显式指定的导出目录写一个 zip,快照目录一字节不动。
|
|
687
|
+
if (segments[0] === 'pre-restore') {
|
|
688
|
+
const sub = segments.slice(1)
|
|
689
|
+
if (sub.length === 0) {
|
|
690
|
+
if (req.method !== 'GET') { writeJson(res, 405, { error: 'method not allowed', code: 'method-not-allowed' }); return }
|
|
691
|
+
writeJson(res, 200, await listPreRestore(snapshotsDir))
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
if (sub.length === 1 && sub[0] === 'export') {
|
|
695
|
+
if (req.method !== 'POST') { writeJson(res, 405, { error: 'method not allowed', code: 'method-not-allowed' }); return }
|
|
696
|
+
const body = await readJsonBody(req)
|
|
697
|
+
if (body === undefined) { writeJson(res, 400, { error: 'invalid-input', code: 'invalid-input' }); return }
|
|
698
|
+
const result = await exportPreRestoreZip(snapshotsDir, body)
|
|
699
|
+
if (!result.ok) {
|
|
700
|
+
writeJson(res, result.status, { error: result.code, code: result.code, ...(result.detail !== undefined ? { detail: result.detail } : {}) })
|
|
701
|
+
return
|
|
702
|
+
}
|
|
703
|
+
// 导出是「用户拿到了一份文件」这件事实,照既有 recovery 子路径的口径落一条历史
|
|
704
|
+
await tryAppendHistory({
|
|
705
|
+
kind: 'recovery',
|
|
706
|
+
result: 'success',
|
|
707
|
+
sections: [],
|
|
708
|
+
source: 'recovery',
|
|
709
|
+
summary: engineMsg('route.recoveryOperationSummary', { action: 'export-pre-restore' }),
|
|
710
|
+
}).catch(() => undefined)
|
|
711
|
+
writeJson(res, 200, result)
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
writeJson(res, 404, { error: 'not found' })
|
|
715
|
+
return
|
|
716
|
+
}
|
|
524
717
|
// ---------------------------------------- S1–S8:工作区 + 会话回滚点
|
|
525
718
|
// 回滚点 = 工作区快照 + 会话游标(宿主按回合自动留点)。**没有手动「拍点」表单**,
|
|
526
719
|
// 因此这里没有 capture 子路径 —— 留点由宿主订阅 session/event 后走引擎。
|
package/src/routes/sync.ts
CHANGED
|
@@ -49,7 +49,7 @@ import { shouldRunOnEnable, readAutosyncConfig, writeAutosyncConfig, clampRemote
|
|
|
49
49
|
import { isValidExportFileName } from '../sync/backup-files.ts'
|
|
50
50
|
import { DeviceFlowStore } from '../sync/github-auth.ts'
|
|
51
51
|
import { channelOf, clearSyncChannel, isWebDavConfig, listCloudConfiguredProviders, parseSyncChannel, readFullSyncConfig, writeSyncConfig, gistChannelView, s3ChannelView, cloudSecretRef, S3_COMPAT_PROVIDERS, GIST_PROVIDER } from '../sync/sync-config.ts'
|
|
52
|
-
import type { CloudS3ChannelView, CloudGistChannelView, CloudSecretWriter, FullSyncConfig, SyncConfig, SyncTransportType } from '../sync/sync-config.ts'
|
|
52
|
+
import type { CloudS3ChannelView, CloudGistChannelView, CloudSecretWriter, FullSyncConfig, SyncConfig, SyncConfigIssue, SyncTransportType } from '../sync/sync-config.ts'
|
|
53
53
|
import { SyncEngine } from '../sync/sync-engine.ts'
|
|
54
54
|
import { isProbeUnsupported } from '../sync/transport.ts'
|
|
55
55
|
import type { ApplyItemsReport } from '../sync/sync-engine.ts'
|
|
@@ -198,8 +198,12 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
198
198
|
*/
|
|
199
199
|
const collectSyncStatus = async (opts: { workDirDetail: boolean }): Promise<Record<string, unknown>> => {
|
|
200
200
|
// 完整双命名空间配置:repoUrl / webdav.url 无论当前通道都回填,
|
|
201
|
-
// 保证 UI 在 git ↔ webdav
|
|
202
|
-
|
|
201
|
+
// 保证 UI 在 git ↔ webdav 间切换时另一通道的地址不丢失。
|
|
202
|
+
// SEC-03 读侧可见性:已落盘但过不了白名单的 repoUrl 会被 readFullSyncConfig 丢弃
|
|
203
|
+
// (绝不交给凭据注入/git 子进程),但**必须把成因带出去** —— 否则界面显示「未配置」,
|
|
204
|
+
// 用户永远看不出自己保存过的地址是被安全校验拒掉,而不是从来没存过。
|
|
205
|
+
const configIssues: SyncConfigIssue[] = []
|
|
206
|
+
const full = await readFullSyncConfig(syncDir, configIssues)
|
|
203
207
|
const state = await loadSyncState(syncDir)
|
|
204
208
|
const [cred, webdavCred] = await Promise.all([
|
|
205
209
|
credentials.describe(credentialRef(SYNC_CREDENTIAL_REF)),
|
|
@@ -231,6 +235,9 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
231
235
|
configured: full !== null,
|
|
232
236
|
transport,
|
|
233
237
|
repoUrl: full?.git?.repoUrl,
|
|
238
|
+
// 被丢弃的通道配置成因(默认空数组:字段恒在,客户端不必判 undefined;
|
|
239
|
+
// 合法配置不产生幻影告警)。code 是机读 token,message 可直接展示。
|
|
240
|
+
configIssues,
|
|
234
241
|
credentialConfigured: cred.configured,
|
|
235
242
|
credentialWritable: cred.writable === true,
|
|
236
243
|
// webdav 配置状态(无 secret 值:口令用 passwordConfigured 布尔标记)
|
|
@@ -302,10 +309,17 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
302
309
|
writeJson(res, 400, { error: msg('sync.workdirRecloneNeedsConfirm') })
|
|
303
310
|
return
|
|
304
311
|
}
|
|
305
|
-
const
|
|
312
|
+
const configIssues: SyncConfigIssue[] = []
|
|
313
|
+
const full = await readFullSyncConfig(syncDir, configIssues)
|
|
306
314
|
const repoUrl = full?.git?.repoUrl ?? ''
|
|
307
315
|
if (repoUrl === '') {
|
|
308
|
-
|
|
316
|
+
// SEC-03:区分「地址被安全校验拒掉」与「从来就没配」。两者都 400、都不重建,
|
|
317
|
+
// 但文案必须说实话 —— 说成「尚未配置」会让用户反复重填同一个被拒地址。
|
|
318
|
+
const rejected = configIssues.find((issue) => issue.channel === 'git')
|
|
319
|
+
writeJson(res, 400, {
|
|
320
|
+
error: rejected === undefined ? msg('sync.workdirNotConfigured') : rejected.message,
|
|
321
|
+
configIssues,
|
|
322
|
+
})
|
|
309
323
|
return
|
|
310
324
|
}
|
|
311
325
|
const rebuilt = await syncWorkDir.rebuild(repoUrl, mode)
|
|
@@ -373,11 +387,15 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
373
387
|
credentials.describe(credentialRef(SYNC_CREDENTIAL_REF)),
|
|
374
388
|
credentials.describe(credentialRef(SYNC_WEBDAV_CREDENTIAL_REF)),
|
|
375
389
|
])
|
|
376
|
-
|
|
390
|
+
// SEC-03:写完就把落盘结果再读一遍(writeSyncConfig 可能因命名空间合并保留旧值),
|
|
391
|
+
// 成因而非「写成功」才是客户端要显示的真相;字段恒在(默认空数组)。
|
|
392
|
+
const configIssues: SyncConfigIssue[] = []
|
|
393
|
+
const cloud = await cloudChannelViews(await readFullSyncConfig(syncDir, configIssues), credentials)
|
|
377
394
|
writeJson(res, 200, {
|
|
378
395
|
ok: true,
|
|
379
396
|
configured: true,
|
|
380
397
|
transport: syncCfg.transport,
|
|
398
|
+
configIssues,
|
|
381
399
|
credentialConfigured: cred.configured,
|
|
382
400
|
// 通道判定消费单一来源 channelOf(B7 守卫扩到覆盖该谓词形态;此前是裸 isWebDavConfig 分支)
|
|
383
401
|
webdav: channelOf(syncCfg) === 'webdav' ? describeWebDavSlot(syncCfg, webdavCred.configured) : undefined,
|
|
@@ -823,8 +841,9 @@ export function syncRoutes(env: RoutesEnv): WebRoute[] {
|
|
|
823
841
|
}
|
|
824
842
|
const syncCfg = await prepareSync(body)
|
|
825
843
|
const engine = makeSyncEngine(syncCfg)
|
|
826
|
-
await withTimeout(engine.deleteSnapshot(snapshotId), ROUTE_TIMEOUT_MS, msg('host.syncPullTimeout'))
|
|
827
|
-
|
|
844
|
+
const deleted = await withTimeout(engine.deleteSnapshot(snapshotId), ROUTE_TIMEOUT_MS, msg('host.syncPullTimeout'))
|
|
845
|
+
// 远端孤儿 blob 回收被放弃时要能看见(RV1-F3):空数组 = 本轮什么都没被放弃。
|
|
846
|
+
writeJson(res, 200, { ok: true, snapshotId, warnings: deleted.warnings })
|
|
828
847
|
} catch (error) {
|
|
829
848
|
writeSyncRouteError(res, error)
|
|
830
849
|
}
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* 危险键(防 prototype pollution);解析失败抛错 → 导入方归为「Backup integrity check failed」。
|
|
11
11
|
* - 一步到位 `verifyChecksumsJson`:解析 + 校验合并,供 m5/导入管线直接调用。
|
|
12
12
|
* - 校验语义:表内每个路径必须在条目集合中存在且 SHA-256 一致;缺失与不符均计入 mismatches。
|
|
13
|
+
* - 反向覆盖(SEC-05):`unverifiedEntries` 算出「ZIP 有、表里没登记」的条目 —— 正向校验看不到
|
|
14
|
+
* 它们,而文件类分区按归档名原样落盘,缺了这条判定就等于「完整性只覆盖一半」。
|
|
13
15
|
*/
|
|
14
16
|
import { parseJsonSafe } from '../utils/json.ts';
|
|
15
17
|
import {
|
|
@@ -92,6 +94,41 @@ export function verifyChecksumsJson(
|
|
|
92
94
|
return verifyChecksums(entries, table);
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
/**
|
|
98
|
+
* 会落盘的文件分区条目「未登记进校验表」时的错误码(写进 `PlanItem.detail`,也进执行报告)。
|
|
99
|
+
*
|
|
100
|
+
* 语义不是「哈希不符」(那由 `import.integrityFailed` 表示,且直接失败),而是
|
|
101
|
+
* 「这个条目根本没被校验表覆盖」—— 完整性声明对它零约束(SEC-05)。
|
|
102
|
+
*/
|
|
103
|
+
export const UNVERIFIED_FILE_ENTRY_CODE = 'unverified-file-entry';
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 上述状态的文案键。字典条目由维护方补齐;缺失时 messages 回退成键名本身、绝不抛错,
|
|
107
|
+
* 所以导入流程不会因为缺一条文案而中断。
|
|
108
|
+
*/
|
|
109
|
+
export const UNVERIFIED_FILE_ENTRY_KEY = 'import.unverifiedFileEntry';
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 校验表的**反向覆盖**:entries 里存在、校验表里没登记的路径。
|
|
113
|
+
*
|
|
114
|
+
* `verifyChecksums` 只正向遍历表里的键(登记的必须存在且 SHA-256 一致),**表外条目它不看**;
|
|
115
|
+
* 而文件类分区是按归档名原样落盘的(`adapters/file-collection.ts` 的 applyItem),
|
|
116
|
+
* 于是「ZIP 里有、checksums 没登记」的文件照样写进目标目录 —— 完整性声明只约束了登记过的那一半。
|
|
117
|
+
*
|
|
118
|
+
* 本函数是「谁没被覆盖」的**单一事实源**:loadBundle 的告警与导入侧的阻断判定共用它,
|
|
119
|
+
* 不各算一遍(两份实现迟早分叉)。判据与既有告警逐字一致:
|
|
120
|
+
* 跳过目录条目(以 `/` 结尾)与调用方给出的结构文件(manifest.json / integrity/checksums.json)。
|
|
121
|
+
*/
|
|
122
|
+
export function unverifiedEntries(
|
|
123
|
+
names: readonly string[],
|
|
124
|
+
table: Record<string, string>,
|
|
125
|
+
exclude: readonly string[] = [],
|
|
126
|
+
): string[] {
|
|
127
|
+
const skip = new Set(exclude);
|
|
128
|
+
const covered = new Set(Object.keys(table));
|
|
129
|
+
return names.filter((name) => !name.endsWith('/') && !skip.has(name) && !covered.has(name));
|
|
130
|
+
}
|
|
131
|
+
|
|
95
132
|
/** 人类可读的不匹配摘要(给「Backup integrity check failed」错误消息用) */
|
|
96
133
|
export function describeMismatches(result: ChecksumVerifyResult): string {
|
|
97
134
|
const parts = [
|