@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/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 && err.code === 'EPERM'; // 存在但无权限 → 视为活着,绝不误踢
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
- async function writeLockFile(lockPath, operation) {
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
- const lock = {
59
- pid: process.pid,
60
- token: randomUUID(),
61
- startedAt: new Date().toISOString(),
62
- heartbeatAt: new Date().toISOString(),
63
- operation: operation || 'unknown',
64
- };
65
- // 原子写:临时文件 + rename,避免并发读锁文件读一半
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
- await open(tmp, 'wx', 0o600).then(async (fh) => { await fh.writeFile(`${JSON.stringify(lock)}\n`, 'utf8'); await fh.close(); });
68
- await rename(tmp, lockPath).catch(async (err) => { await rm(tmp, { force: true }).catch(() => {}); throw err; });
69
- return lock;
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
- * @returns {Promise<{acquired:boolean, lock?:object, reason?:string, heldBy?:object}>}
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 existing = await readLockFile(lockPath);
79
- if (existing && !existing.unreadable && isLockStale(existing)) {
80
- // 死锁:pid 已死,安全回收后重试
81
- await rm(lockPath, { force: true }).catch(() => {});
82
- } else if (existing) {
83
- return { acquired: false, reason: '另一操作正在运行', heldBy: { pid: existing.pid, operation: existing.operation, startedAt: existing.startedAt, heartbeatAt: existing.heartbeatAt } };
84
- }
85
- const lock = await writeLockFile(lockPath, operation);
86
- return { acquired: true, lock };
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 existing = await readLockFile(lockPath);
92
- if (!existing || existing.token !== token) return false;
93
- const lock = { ...existing, heartbeatAt: new Date().toISOString() };
94
- const tmp = `${lockPath}.${randomUUID()}.tmp`;
95
- await open(tmp, 'wx', 0o600).then(async (fh) => { await fh.writeFile(`${JSON.stringify(lock)}\n`, 'utf8'); await fh.close(); });
96
- await rename(tmp, lockPath).catch(async (err) => { await rm(tmp, { force: true }).catch(() => {}); throw err; });
97
- return true;
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 existing = await readLockFile(lockPath);
103
- if (existing && existing.token === token) {
104
- await rm(lockPath, { force: true }).catch(() => {});
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
- return false;
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 existing = await readLockFile(lockPath);
116
- if (!existing) return { reclaimed: false, reason: '没有锁' };
117
- const heldBy = { pid: existing.pid, operation: existing.operation, startedAt: existing.startedAt, heartbeatAt: existing.heartbeatAt };
118
- if (isLockStale(existing) || force) {
119
- await rm(lockPath, { force: true }).catch(() => {});
120
- return { reclaimed: true, heldBy };
121
- }
122
- return { reclaimed: false, reason: '锁持有进程仍在运行(未回收,避免误踢活锁)', heldBy };
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.1",
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 && err.code === 'EPERM';
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);