@xiaoyuyu6420/dsh-backup 0.13.3 → 0.14.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/lib/oplock.js ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * dsh-backup — 操作锁 + 残留锁回收(P3 宿主半边,零依赖)。
3
+ *
4
+ * 目的:备份/恢复/导入/同步/doctor 这类**互斥的重活**同一时刻只该有一个在跑,
5
+ * 否则多实例/多 profile 会互相踩(vault 刷新、.dsh 挪旁、归档轮换都有竞态)。
6
+ *
7
+ * 锁模型:
8
+ * - 锁文件(JSON)记 { pid, token(随机), startedAt, heartbeatAt, operation }。
9
+ * - 持有者每隔 HEARTBEAT_MS 刷新 heartbeatAt(进程活着时必刷)。
10
+ * - 死锁判定**极端保守**:只有当 `process.kill(pid,0)` 报 ESRCH(进程确定不
11
+ * 存在)才判可回收;EPERM(存在但无权限)、kill -0 成功、甚至僵尸态一律
12
+ * 判「可能还活着」→ 绝不自动踢。心跳新鲜/陈旧只作诊断展示,不作为自动
13
+ * 踢锁判据——「绝不误踢活锁」优先于「尽快回收死锁」。
14
+ * - `kill -9` 留下的死锁:pid 已死 → 下次 acquire 自动回收;heartbeat 陈旧
15
+ * 但 pid 存活(卡死/复用)→ 交给**显式** reclaim(force) 由用户确认后回收。
16
+ *
17
+ * 回收入口:命令 `/backup lock reclaim` 与 RPC `reclaimLock`。
18
+ */
19
+
20
+ import { randomUUID } from 'node:crypto';
21
+ import { open, readFile, rm, rename, mkdir } from 'node:fs/promises';
22
+ import { dirname } from 'node:path';
23
+
24
+ /** 心跳刷新间隔(持有者内部用)。 */
25
+ export const LOCK_HEARTBEAT_MS = 30_000;
26
+
27
+ /** 判断 pid 是否可能存活:仅 ESRCH 判死;其余(成功/EPERM/未知)一律判活。 */
28
+ export function isPidAlive(pid) {
29
+ if (!Number.isInteger(pid) || pid <= 0) return false;
30
+ try {
31
+ process.kill(pid, 0);
32
+ return true;
33
+ } catch (err) {
34
+ return err && err.code === 'EPERM'; // 存在但无权限 → 视为活着,绝不误踢
35
+ }
36
+ }
37
+
38
+ /** 死锁判定:pid 确定不存在才算死;心跳只作参考字段,不参与自动判定。 */
39
+ export function isLockStale(lock) {
40
+ if (!lock || typeof lock !== 'object') return true;
41
+ return !isPidAlive(Number(lock.pid));
42
+ }
43
+
44
+ async function readLockFile(lockPath) {
45
+ try {
46
+ return JSON.parse(await readFile(lockPath, 'utf8'));
47
+ } catch (err) {
48
+ if (err && err.code === 'ENOENT') return null;
49
+ // 锁文件损坏(半截写/权限):保守按「存在但读不了」处理,返回一个
50
+ // pid=当前 的伪锁阻止 acquire,避免两个实例同时以为没锁。——但 EACCES
51
+ // 更可能是权限问题,也按不持有处理?不:宁可阻塞也不并发踩踏。
52
+ return { pid: process.pid, token: null, operation: 'unknown', startedAt: null, heartbeatAt: null, unreadable: true };
53
+ }
54
+ }
55
+
56
+ async function writeLockFile(lockPath, operation) {
57
+ 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
+ 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;
70
+ }
71
+
72
+ /**
73
+ * 尝试获取操作锁。
74
+ * @returns {Promise<{acquired:boolean, lock?:object, reason?:string, heldBy?:object}>}
75
+ * acquired=true 时 lock 是本次写下的锁(含 token,供 release/heartbeat 使用)。
76
+ */
77
+ 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 };
87
+ }
88
+
89
+ /** 刷新心跳(仅当 token 匹配、且 pid 仍是本进程时写)。 */
90
+ 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;
98
+ }
99
+
100
+ /** 释放锁(仅 token 匹配才删,绝不删别人的锁)。 */
101
+ 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(() => {});
105
+ return true;
106
+ }
107
+ return false;
108
+ }
109
+
110
+ /**
111
+ * 回收入口:死锁直接回收;活锁默认拒绝,force=true 时按用户显式确认强删。
112
+ * @returns {Promise<{reclaimed:boolean, reason?:string, heldBy?:object}>}
113
+ */
114
+ 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 };
123
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * dsh-backup — 多通道同步的传输层(P3 宿主半边)。
3
+ *
4
+ * 零 npm 依赖:只用 node:crypto(签名/哈希)+ 全局 fetch(Node ≥18)。
5
+ * 三个通道共享同一套「可移植集合」产物:归档 + .sha256 + .meta.json +
6
+ * .redacted.json + .encrypted.json 边车(调用方负责组装文件清单,这里只做
7
+ * 「把一个字节流 PUT 到远端某个路径」的原子动作)。
8
+ *
9
+ * 安全边界(硬性):
10
+ * - 凭据(password/secretKey/token)只作为函数入参内存态流转,绝不写
11
+ * settings.yaml、绝不进日志——下方任何错误信息都不回显凭据本身。
12
+ * - 签名密钥只进 HMAC,不进 Authorization 之外的任何输出。
13
+ */
14
+
15
+ import { createHash, createHmac } from 'node:crypto';
16
+
17
+ const sha256hex = (buf) => createHash('sha256').update(buf).digest('hex');
18
+ const hmac = (key, data) => createHmac('sha256', key).update(data).digest();
19
+
20
+ /** 把 Buffer 切成 ASCII 十六进制(S3 SigV4 需要) */
21
+ const hmacHex = (key, data) => createHmac('sha256', key).update(data).digest('hex');
22
+
23
+ /**
24
+ * 计算 AWS Signature V4 请求签名。仅覆盖本插件需要的 PUT(无 query、无
25
+ * content-type、固定头集 host;x-amz-content-sha256;x-amz-date)。路径只做
26
+ * 「按 / 分段 encodeURIComponent」的保守编码——我们的 key 都是 `dsh-…` /
27
+ * `dsh-config-…` 这类安全文件名,不出现需要特殊处理的字符。
28
+ *
29
+ * @returns {{authorization:string, amzDate:string, payloadHash:string, host:string}}
30
+ */
31
+ function sigv4Sign({ method, host, path, region, accessKey, secretKey, body, service = 's3' }) {
32
+ const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ''); // YYYYMMDDTHHMMSSZ
33
+ const dateStamp = amzDate.slice(0, 8);
34
+ const payloadHash = sha256hex(body);
35
+ // 规范路径:按段编码,保留段内已有的 / 分隔(我们的 key 不含需编码字符)
36
+ const canonicalUri = path.split('/').map((seg) => encodeURIComponent(seg)).join('/');
37
+ const canonicalHeaders = `host:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`;
38
+ const signedHeaders = 'host;x-amz-content-sha256;x-amz-date';
39
+ const canonicalRequest = [method, canonicalUri, '', canonicalHeaders, signedHeaders, payloadHash].join('\n');
40
+ const scope = `${dateStamp}/${region}/${service}/aws4_request`;
41
+ const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256hex(Buffer.from(canonicalRequest, 'utf8'))].join('\n');
42
+ const kDate = hmac(Buffer.from(`AWS4${secretKey}`, 'utf8'), dateStamp);
43
+ const kRegion = hmac(kDate, region);
44
+ const kService = hmac(kRegion, service);
45
+ const kSigning = hmac(kService, 'aws4_request');
46
+ const signature = hmacHex(kSigning, stringToSign);
47
+ const authorization = `AWS4-HMAC-SHA256 Credential=${accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
48
+ return { authorization, amzDate, payloadHash, host };
49
+ }
50
+
51
+ /** 读一个 Buffer 走 fetch,2xx 视为成功;非 2xx 抛「人话 + HTTP 状态」,不回显凭据。 */
52
+ async function fetchPut(url, { headers, body, signal, auth = null }) {
53
+ const h = { ...headers };
54
+ if (auth) h.authorization = auth;
55
+ let res;
56
+ try {
57
+ res = await fetch(url, { method: 'PUT', headers: h, body, signal, redirect: 'follow' });
58
+ } catch (err) {
59
+ if (err && err.name === 'AbortError') throw new Error('操作已取消');
60
+ throw new Error(`网络请求失败(${String(err && err.message ? err.message : err).slice(0, 160)})`);
61
+ }
62
+ if (res.ok) return { ok: true };
63
+ let detail = '';
64
+ try { detail = (await res.text()).slice(0, 200); } catch { /* 忽略响应体读取失败 */ }
65
+ throw new Error(`远端返回 HTTP ${res.status}(${detail || res.statusText})`);
66
+ }
67
+
68
+ /**
69
+ * S3 兼容 PUT(AWS S3 / MinIO / Aliyun OSS / Tencent COS / Qiniu KODO 的
70
+ * S3 兼容入口均走同一套 SigV4 + 可配置 endpoint)。pathStyle=true 用
71
+ * `/{bucket}/{key}`(MinIO/自托管/大多数兼容层);false 用虚拟主机风格
72
+ * `https://{bucket}.{host}/{key}`。
73
+ *
74
+ * @param {{endpoint:string, region:string, bucket:string, key:string, body:Buffer,
75
+ * accessKey:string, secretKey:string, pathStyle?:boolean, signal?:AbortSignal}} cfg
76
+ * @returns {Promise<{ok:boolean}>}
77
+ */
78
+ export async function s3Put(cfg) {
79
+ const { endpoint, region, bucket, key, body, accessKey, secretKey, pathStyle = true, signal } = cfg;
80
+ const u = new URL(endpoint.endsWith('/') ? endpoint : `${endpoint}/`);
81
+ const basePath = u.pathname.replace(/\/+$/, '');
82
+ const objPath = pathStyle
83
+ ? `${basePath}/${bucket}/${key}`.replace(/\/{2,}/g, '/')
84
+ : `${basePath}/${key}`.replace(/\/{2,}/g, '/');
85
+ const host = pathStyle ? u.host : `${bucket}.${u.host}`;
86
+ const sign = sigv4Sign({ method: 'PUT', host, path: objPath, region, accessKey, secretKey, body, service: 's3' });
87
+ const url = `${u.protocol}//${host}${objPath}`;
88
+ return fetchPut(url, {
89
+ headers: {
90
+ 'x-amz-date': sign.amzDate,
91
+ 'x-amz-content-sha256': sign.payloadHash,
92
+ 'content-type': 'application/octet-stream',
93
+ },
94
+ body,
95
+ signal,
96
+ auth: sign.authorization,
97
+ });
98
+ }
99
+
100
+ /**
101
+ * WebDAV PUT:用 Basic auth(username/password)把字节流 PUT 到远端路径,
102
+ * 并先 MKCOL 建立父集合(已存在返回 405 可容忍)。url 形如
103
+ * `https://host/dav/`,key 拼在其后。
104
+ */
105
+ export async function webdavPut({ url, username, password, key, body, signal }) {
106
+ const base = url.endsWith('/') ? url : `${url}/`;
107
+ // 逐层 MKCOL 父目录(容错 405/409:已存在)
108
+ const segs = key.split('/').slice(0, -1);
109
+ let dir = base;
110
+ const auth = `Basic ${Buffer.from(`${username || ''}:${password || ''}`, 'utf8').toString('base64')}`;
111
+ for (const seg of segs) {
112
+ if (!seg) continue;
113
+ dir += `${encodeURIComponent(seg)}/`;
114
+ try {
115
+ await fetch(dir, { method: 'MKCOL', headers: { authorization: auth }, signal });
116
+ } catch { /* 网络错误下放到最终 PUT 报错 */ }
117
+ }
118
+ return fetchPut(`${base}${key.split('/').map((s) => encodeURIComponent(s)).join('/')}`, {
119
+ headers: { 'content-type': 'application/octet-stream' },
120
+ body,
121
+ signal,
122
+ auth,
123
+ });
124
+ }
125
+
126
+ /**
127
+ * GitHub Gist 同步:创建/更新一个 gist。gist 内容只能是文本,故二进制归档
128
+ * 以 base64 编码、边车(JSON,已是文本)原样放入。`files` = { [远端文件名]:
129
+ * { content: string } }。gistId 为空则创建(POST),否则更新(PATCH)。
130
+ * 调用方负责 size 上限(gist 有 300MB 总上限、单文件约 10MB)。
131
+ */
132
+ export async function gistSync({ token, gistId, files, description, signal, apiBase = 'https://api.github.com' }) {
133
+ const headers = {
134
+ authorization: `token ${token}`,
135
+ accept: 'application/vnd.github+json',
136
+ 'content-type': 'application/json',
137
+ };
138
+ const payload = { description: description || 'dsh-backup sync', public: false, files };
139
+ const url = gistId
140
+ ? `${apiBase}/gists/${encodeURIComponent(gistId)}`
141
+ : `${apiBase}/gists`;
142
+ let res;
143
+ try {
144
+ res = await fetch(url, { method: gistId ? 'PATCH' : 'POST', headers, body: JSON.stringify(payload), signal });
145
+ } catch (err) {
146
+ if (err && err.name === 'AbortError') throw new Error('操作已取消');
147
+ throw new Error(`Gist 请求失败(${String(err && err.message ? err.message : err).slice(0, 160)})`);
148
+ }
149
+ if (!res.ok) {
150
+ let detail = '';
151
+ try { detail = (await res.text()).slice(0, 200); } catch { /* 忽略 */ }
152
+ throw new Error(`Gist API 返回 HTTP ${res.status}(${detail || res.statusText})`);
153
+ }
154
+ const data = await res.json();
155
+ return { ok: true, gistId: data?.id || gistId, htmlUrl: data?.html_url || null };
156
+ }
157
+
158
+ /** 暴露给调用方:把 Buffer 转 gist 文件内容(二进制 base64、JSON 文本原样)。 */
159
+ export function gistContent(buf, { binary = false } = {}) {
160
+ return binary ? buf.toString('base64') : buf.toString('utf8');
161
+ }