@xiaoyuyu6420/dsh-backup 0.14.1 → 0.14.3
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.en.md +4 -0
- package/README.md +6 -2
- package/lib/client.js +3 -3
- package/lib/encrypt.js +111 -12
- package/lib/index.js +251 -117
- package/lib/oplock.js +77 -53
- package/lib/paths.js +56 -0
- package/lib/redact.js +37 -0
- package/package.json +1 -1
- package/rescue/rescue.mjs +34 -3
package/lib/oplock.js
CHANGED
|
@@ -31,7 +31,7 @@ export function isPidAlive(pid) {
|
|
|
31
31
|
process.kill(pid, 0);
|
|
32
32
|
return true;
|
|
33
33
|
} catch (err) {
|
|
34
|
-
return err
|
|
34
|
+
return err?.code !== 'ESRCH'; // Only a confirmed missing process may be reclaimed
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -53,71 +53,95 @@ async function readLockFile(lockPath) {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
/**
|
|
57
|
+
* A short filesystem transaction serializes all lock mutations across processes.
|
|
58
|
+
* mkdir is exclusive; atomic rename of JSON alone is not mutual exclusion.
|
|
59
|
+
* An unreadable/orphaned guard fails closed (never steal a potentially live writer).
|
|
60
|
+
* The owner record identifies the process for manual recovery after an interrupted
|
|
61
|
+
* metadata transaction. Normal crash recovery uses the main lock's dead PID.
|
|
62
|
+
*/
|
|
63
|
+
export async function withLockGuard(lockPath, fn, { wait = false } = {}) {
|
|
64
|
+
const guard = `${lockPath}.guard`;
|
|
57
65
|
await mkdir(dirname(lockPath), { recursive: true });
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
for (let attempt = 0; ; attempt++) {
|
|
67
|
+
try {
|
|
68
|
+
await mkdir(guard, { mode: 0o700 });
|
|
69
|
+
break;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (err.code !== 'EEXIST') throw err;
|
|
72
|
+
if (!wait || attempt >= 100) return { busy: true, guard };
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const fh = await open(`${guard}/owner.json`, 'wx', 0o600);
|
|
78
|
+
try { await fh.writeFile(JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })); }
|
|
79
|
+
finally { await fh.close(); }
|
|
80
|
+
return { value: await fn() };
|
|
81
|
+
} finally {
|
|
82
|
+
await rm(guard, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function writeLockFile(lockPath, lock) {
|
|
66
87
|
const tmp = `${lockPath}.${randomUUID()}.tmp`;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
88
|
+
try {
|
|
89
|
+
const fh = await open(tmp, 'wx', 0o600);
|
|
90
|
+
try { await fh.writeFile(`${JSON.stringify(lock)}\n`, 'utf8'); }
|
|
91
|
+
finally { await fh.close(); }
|
|
92
|
+
await rename(tmp, lockPath);
|
|
93
|
+
} finally {
|
|
94
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
95
|
+
}
|
|
70
96
|
}
|
|
71
97
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
* acquired=true 时 lock 是本次写下的锁(含 token,供 release/heartbeat 使用)。
|
|
76
|
-
*/
|
|
98
|
+
const heldBy = (lock) => ({ pid: lock.pid, operation: lock.operation, startedAt: lock.startedAt, heartbeatAt: lock.heartbeatAt });
|
|
99
|
+
const busyReason = (guard) => `锁元数据正在更新(${guard});若进程异常退出,请先确认 guard/owner.json 中的进程已结束再清理该目录`;
|
|
100
|
+
|
|
77
101
|
export async function acquireLock(lockPath, operation) {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
102
|
+
const result = await withLockGuard(lockPath, async () => {
|
|
103
|
+
const existing = await readLockFile(lockPath);
|
|
104
|
+
if (existing && (existing.unreadable || !isLockStale(existing))) {
|
|
105
|
+
return { acquired: false, reason: '另一操作正在运行', heldBy: heldBy(existing) };
|
|
106
|
+
}
|
|
107
|
+
const now = new Date().toISOString();
|
|
108
|
+
const lock = { pid: process.pid, token: randomUUID(), startedAt: now, heartbeatAt: now, operation: operation || 'unknown' };
|
|
109
|
+
await writeLockFile(lockPath, lock);
|
|
110
|
+
return { acquired: true, lock };
|
|
111
|
+
});
|
|
112
|
+
return result.busy ? { acquired: false, reason: busyReason(result.guard) } : result.value;
|
|
87
113
|
}
|
|
88
114
|
|
|
89
|
-
/** 刷新心跳(仅当 token 匹配、且 pid 仍是本进程时写)。 */
|
|
90
115
|
export async function heartbeatLock(lockPath, token) {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return
|
|
116
|
+
const result = await withLockGuard(lockPath, async () => {
|
|
117
|
+
const existing = await readLockFile(lockPath);
|
|
118
|
+
if (!existing || existing.token !== token || existing.pid !== process.pid) return false;
|
|
119
|
+
await writeLockFile(lockPath, { ...existing, heartbeatAt: new Date().toISOString() });
|
|
120
|
+
return true;
|
|
121
|
+
}, { wait: true });
|
|
122
|
+
return result.busy ? false : result.value;
|
|
98
123
|
}
|
|
99
124
|
|
|
100
|
-
/** 释放锁(仅 token 匹配才删,绝不删别人的锁)。 */
|
|
101
125
|
export async function releaseLock(lockPath, token) {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
126
|
+
const result = await withLockGuard(lockPath, async () => {
|
|
127
|
+
const existing = await readLockFile(lockPath);
|
|
128
|
+
if (!existing || existing.token !== token) return false;
|
|
129
|
+
await rm(lockPath, { force: true });
|
|
105
130
|
return true;
|
|
106
|
-
}
|
|
107
|
-
|
|
131
|
+
}, { wait: true });
|
|
132
|
+
if (result.busy) throw new Error(busyReason(result.guard));
|
|
133
|
+
return result.value;
|
|
108
134
|
}
|
|
109
135
|
|
|
110
|
-
/**
|
|
111
|
-
* 回收入口:死锁直接回收;活锁默认拒绝,force=true 时按用户显式确认强删。
|
|
112
|
-
* @returns {Promise<{reclaimed:boolean, reason?:string, heldBy?:object}>}
|
|
113
|
-
*/
|
|
114
136
|
export async function reclaimLock(lockPath, { force = false } = {}) {
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
137
|
+
const result = await withLockGuard(lockPath, async () => {
|
|
138
|
+
const existing = await readLockFile(lockPath);
|
|
139
|
+
if (!existing) return { reclaimed: false, reason: '没有锁' };
|
|
140
|
+
if (isLockStale(existing) || force) {
|
|
141
|
+
await rm(lockPath, { force: true });
|
|
142
|
+
return { reclaimed: true, heldBy: heldBy(existing) };
|
|
143
|
+
}
|
|
144
|
+
return { reclaimed: false, reason: '锁持有进程仍在运行(未回收,避免误踢活锁)', heldBy: heldBy(existing) };
|
|
145
|
+
});
|
|
146
|
+
return result.busy ? { reclaimed: false, reason: busyReason(result.guard) } : result.value;
|
|
123
147
|
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { lstat } from 'node:fs/promises';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Resolve existing ancestors too: a destination may not exist yet or may be
|
|
6
|
+
// reached through a symlink outside DSH_HOME.
|
|
7
|
+
function canonicalPath(value) {
|
|
8
|
+
const abs = resolve(value);
|
|
9
|
+
try { return realpathSync(abs); } catch (err) {
|
|
10
|
+
if (err.code !== 'ENOENT' || dirname(abs) === abs) throw err;
|
|
11
|
+
return resolve(canonicalPath(dirname(abs)), basename(abs));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Relative backup subtree to exclude, or null for an external destination. */
|
|
16
|
+
export function backupRootRelative(dshHome, root) {
|
|
17
|
+
const rel = relative(canonicalPath(dshHome), canonicalPath(root));
|
|
18
|
+
if (!rel) throw new Error('备份目录不能等于 DSH_HOME,请选择独立的备份目录');
|
|
19
|
+
return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel) ? null : rel.split(sep).join('/');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** tar --exclude is a glob, even when the user configured a literal directory. */
|
|
23
|
+
export function tarLiteralPattern(value) {
|
|
24
|
+
return value.replace(/[\\*?\[\]]/g, '\\$&');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Portable relative paths only; metadata is untrusted even when ZIP names are safe. */
|
|
28
|
+
export function safeRelativePath(value) {
|
|
29
|
+
if (typeof value !== 'string' || !value || /[\\\u0000-\u001f\u007f:]/.test(value)
|
|
30
|
+
|| value.startsWith('/') || value.split('/').some((part) => !part || part === '.' || part === '..'
|
|
31
|
+
|| /[. ]$/.test(part) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) {
|
|
32
|
+
throw new Error(`不安全的相对路径: ${String(value)}`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Reject existing symlinks as well as lexical traversal before writing into a tree. */
|
|
38
|
+
export async function safeDestination(root, rel) {
|
|
39
|
+
safeRelativePath(rel);
|
|
40
|
+
const base = resolve(root);
|
|
41
|
+
const target = resolve(base, rel);
|
|
42
|
+
const inside = relative(base, target);
|
|
43
|
+
if (!inside || inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside)) {
|
|
44
|
+
throw new Error(`写入路径越界: ${rel}`);
|
|
45
|
+
}
|
|
46
|
+
let current = base;
|
|
47
|
+
for (const part of ['', ...rel.split('/')]) {
|
|
48
|
+
if (part) current = resolve(current, part);
|
|
49
|
+
const info = await lstat(current).catch((err) => {
|
|
50
|
+
if (err.code === 'ENOENT') return null;
|
|
51
|
+
throw err;
|
|
52
|
+
});
|
|
53
|
+
if (info?.isSymbolicLink()) throw new Error(`写入路径含符号链接: ${rel}`);
|
|
54
|
+
}
|
|
55
|
+
return target;
|
|
56
|
+
}
|
package/lib/redact.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const literal = (c) => c.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
|
|
2
|
+
|
|
3
|
+
// tar excludes are unanchored globs and '*' may cross directory separators.
|
|
4
|
+
// POSIX character classes depend on the tar process locale; conservatively match
|
|
5
|
+
// any character for those classes instead of risking a plaintext ZIP disclosure.
|
|
6
|
+
export function matchesRedaction(rel, pattern) {
|
|
7
|
+
let source = '';
|
|
8
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
9
|
+
const c = pattern[i];
|
|
10
|
+
if (c === '*') source += '[\\s\\S]*';
|
|
11
|
+
else if (c === '?') source += '[\\s\\S]';
|
|
12
|
+
else if (c === '\\' && i + 1 < pattern.length) source += literal(pattern[++i]);
|
|
13
|
+
else if (c === '[') {
|
|
14
|
+
let end = i + 1;
|
|
15
|
+
if (pattern[end] === '!' || pattern[end] === '^') end++;
|
|
16
|
+
if (pattern[end] === ']') end++;
|
|
17
|
+
while (end < pattern.length && pattern[end] !== ']') {
|
|
18
|
+
if (pattern[end] === '[' && ':=.'.includes(pattern[end + 1] ?? ' ')) {
|
|
19
|
+
const innerEnd = pattern.indexOf(`${pattern[end + 1]}]`, end + 2);
|
|
20
|
+
if (innerEnd !== -1) { end = innerEnd + 2; continue; }
|
|
21
|
+
}
|
|
22
|
+
end++;
|
|
23
|
+
}
|
|
24
|
+
if (end === pattern.length) { source += '\\['; continue; }
|
|
25
|
+
const body = pattern.slice(i + 1, end);
|
|
26
|
+
const negated = body.startsWith('!') || body.startsWith('^');
|
|
27
|
+
const chars = (negated ? body.slice(1) : body).replace(/\\/g, '\\\\').replace(/\]/g, '\\]');
|
|
28
|
+
const bracket = `[${negated ? '^' : ''}${chars}]`;
|
|
29
|
+
try {
|
|
30
|
+
new RegExp(bracket);
|
|
31
|
+
source += body.includes('[') ? '[\\s\\S]' : bracket;
|
|
32
|
+
} catch { source += '[\\s\\S]'; }
|
|
33
|
+
i = end;
|
|
34
|
+
} else source += literal(c);
|
|
35
|
+
}
|
|
36
|
+
return new RegExp(`(?:^|/)${source}(?:/|$)`).test(rel);
|
|
37
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiaoyuyu6420/dsh-backup",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.3",
|
|
4
4
|
"description": "一条命令备份、恢复与迁移 DeepSeek Harness(dsh)的全部数据(~/.dsh:会话、设置、插件、MCP、技能):定时自动备份、升级前快照、换机迁移、会话日志体检修复、宿主起不来也能用的进程外救援;凭据默认脱敏只存本机。 Backup, restore & migrate all DeepSeek Harness (~/.dsh) data — sessions, settings, plugins, MCP, skills: scheduled backups, pre-upgrade snapshots, cross-machine migration, session-log doctor, out-of-process rescue console; credentials redacted to a local-only vault.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.24.0",
|
package/rescue/rescue.mjs
CHANGED
|
@@ -93,8 +93,6 @@ function run(argv, cwd) {
|
|
|
93
93
|
const parentTar = (p) => (p.endsWith('/') ? p : `${p}/`);
|
|
94
94
|
|
|
95
95
|
async function sha256File(absPath) {
|
|
96
|
-
const info = await fs.stat(absPath);
|
|
97
|
-
if (info.size > HASH_MAX_BYTES) throw new Error(`文件 ${Math.floor(info.size / 1048576)}MB 超过哈希上限`);
|
|
98
96
|
return new Promise((resolve, reject) => {
|
|
99
97
|
const h = createHash('sha256');
|
|
100
98
|
createReadStream(absPath).on('data', (d) => h.update(d)).on('error', reject).on('end', () => resolve(h.digest('hex')));
|
|
@@ -510,6 +508,17 @@ async function restoreArchive(root, selector, apply) {
|
|
|
510
508
|
return { dryRun: true, archive: picked.name, files: entries.length, sample: entries.slice(0, 12), preflight, profileDirs };
|
|
511
509
|
}
|
|
512
510
|
|
|
511
|
+
// Keep this standalone rescue script dependency-free. A nested backup root
|
|
512
|
+
// would disappear when the source tree is moved aside during replacement.
|
|
513
|
+
const sourceReal = await fs.realpath(dshHome).catch((err) => {
|
|
514
|
+
if (err.code === 'ENOENT') return path.resolve(dshHome);
|
|
515
|
+
throw err;
|
|
516
|
+
});
|
|
517
|
+
const backupRel = path.relative(sourceReal, await fs.realpath(root));
|
|
518
|
+
if (!backupRel || (backupRel !== '..' && !backupRel.startsWith(`..${path.sep}`) && !path.isAbsolute(backupRel))) {
|
|
519
|
+
throw new Error('整包恢复前请把整个备份目录(含 vault)移到 DSH_HOME 外,并更新备份路径;当前备份目录位于待替换的数据目录内');
|
|
520
|
+
}
|
|
521
|
+
|
|
513
522
|
// 现有数据:快照 + 挪旁;缺失(新机/全失)则直接解压
|
|
514
523
|
let targetExists = true;
|
|
515
524
|
try {
|
|
@@ -755,12 +764,30 @@ function isPidAlive(pid) {
|
|
|
755
764
|
process.kill(pid, 0);
|
|
756
765
|
return true;
|
|
757
766
|
} catch (err) {
|
|
758
|
-
return err
|
|
767
|
+
return err?.code !== 'ESRCH';
|
|
759
768
|
}
|
|
760
769
|
}
|
|
761
770
|
|
|
762
771
|
/** 回收残留死锁(owner 证明已死才回收)。 */
|
|
763
772
|
async function recoverStaleLock(root) {
|
|
773
|
+
// Use the host's same exclusive transaction guard: never remove a lock while
|
|
774
|
+
// a live host is publishing a new owner or heartbeat.
|
|
775
|
+
const guard = `${root}/${LOCK_FILE}.guard`;
|
|
776
|
+
await fs.mkdir(root, { recursive: true });
|
|
777
|
+
try { await fs.mkdir(guard, { mode: 0o700 }); }
|
|
778
|
+
catch (err) {
|
|
779
|
+
if (err.code === 'EEXIST') throw new Error(`锁元数据正在更新(${guard}),请稍后重试`);
|
|
780
|
+
throw err;
|
|
781
|
+
}
|
|
782
|
+
try {
|
|
783
|
+
await fs.writeFile(`${guard}/owner.json`, JSON.stringify({ pid: process.pid }), { mode: 0o600 });
|
|
784
|
+
return await recoverStaleLockInner(root);
|
|
785
|
+
} finally {
|
|
786
|
+
await fs.rm(guard, { recursive: true, force: true });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async function recoverStaleLockInner(root) {
|
|
764
791
|
const lockPath = `${root}/${LOCK_FILE}`;
|
|
765
792
|
let lock;
|
|
766
793
|
try {
|
|
@@ -830,6 +857,10 @@ async function sessionsRepair(root, dshHome, argv) {
|
|
|
830
857
|
}
|
|
831
858
|
}
|
|
832
859
|
if (!maps.length && keep === undefined) throw new Error('sessions repair 至少需要 --map old=new 或 --keep <id>');
|
|
860
|
+
// --map 会**改写会话 header 里的 cwd**(动用户自己的日志),必须显式确认。
|
|
861
|
+
if (fix && maps.length && !argv.includes('--yes')) {
|
|
862
|
+
throw new Error('sessions repair --fix --map 会原地改写会话 header 的 cwd(动用户日志)——确认请加 --yes;不加 --fix 时仅预览');
|
|
863
|
+
}
|
|
833
864
|
|
|
834
865
|
const files = [];
|
|
835
866
|
await collectSessionLogs(`${dshHome}/sessions`, '', 0, files);
|