@xiaoyuyu6420/dsh-backup 0.10.0 → 0.11.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 -0
- package/README.zh.md +1 -0
- package/lib/client.js +1 -1
- package/lib/index.js +32 -12
- package/package.json +1 -1
- package/rescue/rescue.mjs +25 -9
package/README.md
CHANGED
|
@@ -131,6 +131,7 @@ Tried it? Tell us what broke, what's missing, what you liked — it directly sha
|
|
|
131
131
|
<details>
|
|
132
132
|
<summary>Recent releases</summary>
|
|
133
133
|
|
|
134
|
+
- **0.11.0** — doctor container-contract check: the first zstd frame must decode to exactly one header line, byte-precise (non-empty, first newline at the last byte — aligned with the host reader). Single-frame rewrites, stray blank lines in the first frame, a missing trailing newline and skippable frames are now flagged corrupt (previously reported healthy while the host refused to load them); the rescue console checks in sync. Found via a community audit on deepseek-harness #1047.
|
|
134
135
|
- **0.10.0** — typed backups: back up just what you need (`/backup --types skills,sessions`) and merge-restore a subset (`/backup restore <archive> --types skills`); per-type archives rotate in their own bucket. Credential-type archives stay out of GitHub sync; cross-machine guardrails unchanged.
|
|
135
136
|
- **0.9.1** — feedback entry point in the panel; README overhaul. UX hardening from a six-agent review: restore-confirm button made visible again (missing theme fallback), snapshot self-deletion during snapshot-restore fixed, node discovery for the double-click rescue launcher, friendlier error messages with concrete next steps.
|
|
136
137
|
- **0.9.0** — `/backup doctor` session-log health check with targeted repair; out-of-process rescue channel (`dsh-rescue` / launcher in the backup dir) that works even when the host won't boot; restore auto-rollback with a result-oriented receipt; smart backup: pre-upgrade snapshots on host train changes, quarantine of corrupt session logs before they rotate away, tiered daily/weekly retention.
|
package/README.zh.md
CHANGED
|
@@ -131,6 +131,7 @@ dsh plugin --profile web add github:xiaoyuyu6420/dsh-backup
|
|
|
131
131
|
<details>
|
|
132
132
|
<summary>最近的版本</summary>
|
|
133
133
|
|
|
134
|
+
- **0.11.0** —— doctor 容器契约校验:首帧必须解出「恰好一行 header、单个换行结尾」(字节精确,对齐宿主读端)。单帧重写、首帧多余空行、缺行尾、skippable 帧现在都会判损坏(此前报健康但宿主拒载);救援台同步。来自 deepseek-harness 官方讨论区 #1047 的社区审计。
|
|
134
135
|
- **0.10.0** —— 分类型备份:只备份需要的部分(`/backup --types skills,sessions`),按类型 merge 恢复(`/backup restore <归档> --types skills`);类型归档独立轮换不挤全量配额。含凭据的类型归档永不进 GitHub 同步,跨机护栏不变。
|
|
135
136
|
- **0.9.1** —— 面板新增反馈入口;README 重做。六路 agent UX 深审后的加固:恢复确认按钮补主题色兜底重新可见、修复恢复快照时自毁目标快照的 bug、双击救援启动器自动探测 node、报错文案全面改为"人话 + 下一步"。
|
|
136
137
|
- **0.9.0** —— `/backup doctor` 会话日志体检与定点修复;宿主起不来也能用的进程外救援通道(备份目录里的 dsh-rescue / 双击启动器);恢复失败自动回滚与结果导向回执;智能备份:宿主升级前自动快照、损坏会话先隔离再入档、每日/每周分级保留。
|
package/lib/client.js
CHANGED
|
@@ -450,5 +450,5 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
|
|
|
450
450
|
color: var(--dsw-alias-state-error-primary, #e53e3e);
|
|
451
451
|
line-height: 1.5;
|
|
452
452
|
}
|
|
453
|
-
`;var pm={name:"@xiaoyuyu6420/dsh-backup",version:"0.
|
|
453
|
+
`;var pm={name:"@xiaoyuyu6420/dsh-backup",version:"0.11.0",description:"Backup, restore, download and GitHub-sync DeepSeek Harness user data (~/.dsh): /backup, scheduled auto-backup that survives restarts, sha256 checksums, integrity verify, rotation, credential redaction with a local vault (plaintext never leaves the machine), cross-machine restore preflight with github pull, and a visual Settings panel. Cross-platform (macOS/Linux/Windows). \u4E00\u952E\u5907\u4EFD\u4E0E\u6062\u590D DSH \u6570\u636E\uFF1A\u5B9A\u65F6\u81EA\u52A8\u5907\u4EFD\u3001\u5B8C\u6574\u6027\u6821\u9A8C\u3001\u51ED\u636E\u9ED8\u8BA4\u8131\u654F\uFF08\u660E\u6587\u53EA\u5B58\u672C\u673A vault\uFF09\u3001\u8DE8\u673A\u6062\u590D\u9884\u68C0\u4E0E github pull \u62C9\u53D6\uFF0C\u9644 Settings \u53EF\u89C6\u9762\u677F\u3002",type:"module",packageManager:"pnpm@11.24.0",main:"lib/index.js",exports:{".":"./lib/index.js","./client":"./lib/client.js","./cordis.patch.yml":"./cordis.patch.yml","./package.json":"./package.json"},files:["lib","rescue","cordis.patch.yml","README.md","README.zh.md","LICENSE"],bin:{"dsh-rescue":"./rescue/rescue.mjs"},dsh:{bundle:{patch:"./cordis.patch.yml"},client:{platform:"web",inject:["@deepseek-ai/dsh-client-runtime","@deepseek-ai/dsh-client-locale","@deepseek-ai/dsh-client-ui-settings"]}},keywords:["dsh","dsh-plugin","deepseek-harness","backup","\u5907\u4EFD","restore","\u6062\u590D","schedule","\u81EA\u52A8\u5907\u4EFD","cross-platform","rescue","doctor","upgrade-snapshot","\u6570\u636E\u5B89\u5168"],peerDependencies:{"@deepseek-ai/cordis":"^4.0.1","@deepseek-ai/dsh-commands":"^0.1.1-rc.2","@deepseek-ai/dsh-fs":"^0.1.1-rc.2","@deepseek-ai/dsh-settings":"^0.1.1-rc.2","@deepseek-ai/dsh-subprocess":"^0.1.1-rc.2","@deepseek-ai/dsh-tools":"^0.1.1-rc.2","@deepseek-ai/dsh-typert-protocol":"^0.1.1-rc.2","@deepseek-ai/schemastery":"^3.18.1"},devDependencies:{esbuild:"^0.25.0",react:"^19.0.0","react-dom":"^19.0.0",zod:"^4.4.3"},engines:{node:">=20"},license:"MIT",repository:{type:"git",url:"git+https://github.com/xiaoyuyu6420/dsh-backup.git"},homepage:"https://github.com/xiaoyuyu6420/dsh-backup#readme",bugs:{url:"https://github.com/xiaoyuyu6420/dsh-backup/issues"},publishConfig:{access:"public"}};var Cn="settings.backupPanel",Ig=pm.name,jg=["slots","locale","remote"],Pg=y.object({destination:y.string(),dshHome:y.string(),keepDefault:y.number().int(),autoHours:y.number().int(),lastAuto:y.string().nullable(),backups:y.array(y.object({name:y.string(),size:y.number().int().nullable()})),typedBackups:y.array(y.object({name:y.string(),size:y.number().int().nullable(),types:y.array(y.string())})).optional()}),Ug=y.object({ok:y.boolean(),summary:y.string(),path:y.string(),sha:y.string(),stale:y.number().int(),keep:y.number().int(),types:y.array(y.string()).optional(),hasCredentials:y.boolean().optional()}),Dg=y.object({ok:y.boolean(),summary:y.string(),results:y.array(y.object({name:y.string(),ok:y.boolean(),note:y.string()}))}),Og=y.object({ok:y.boolean(),dryRun:y.boolean(),summary:y.string(),archive:y.string().nullable().optional(),files:y.number().int().nullable().optional(),aside:y.string().nullable().optional(),snapshotPath:y.string().nullable().optional(),sample:y.array(y.string()).optional(),preflight:y.array(y.string()).optional(),targetExists:y.boolean().nullable().optional(),merge:y.boolean().optional(),types:y.array(y.string()).optional(),willOverwrite:y.array(y.string()).optional(),restored:y.number().int().optional(),kept:y.array(y.string()).optional()}),Ng=y.object({ok:y.boolean(),hours:y.number().int(),summary:y.string()}),Zg=y.object({repoRaw:y.string().nullable(),repo:y.string().nullable(),tokenSet:y.boolean(),syncDir:y.string(),lastPush:y.string().nullable(),lastError:y.string().nullable()}),Tg=y.object({ok:y.boolean(),summary:y.string(),pushed:y.boolean(),tooBig:y.array(y.string())}),Eg=y.object({ok:y.boolean(),summary:y.string(),pulled:y.array(y.string()),corrupt:y.array(y.string()),total:y.number().int()}),Ag=y.object({ok:y.boolean(),summary:y.string()}),Lg=y.object({ok:y.boolean(),repo:y.string().nullable(),summary:y.string()}),Rg={name:"keep",wire:"keep",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#keep",schema:y.number().int().positive().optional()},acceptsUndefined:!0},Hu={name:"selector",wire:"selector",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#selector",schema:y.string().optional()},acceptsUndefined:!0},Cg={name:"dryRun",wire:"dryRun",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#dryRun",schema:y.boolean().optional()},acceptsUndefined:!0},Fg={name:"syncDeps",wire:"syncDeps",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#syncDeps",schema:y.boolean().optional()},acceptsUndefined:!0},Jg={name:"hours",wire:"hours",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#hours",schema:y.number().int().min(0).max(720)},acceptsUndefined:!0},Vg={name:"repo",wire:"repo",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#repo",schema:y.string().optional()},acceptsUndefined:!0},fm={name:"types",wire:"types",source:"json",codec:{mode:"strict",typeSymbol:"dsh-backup/types#types",schema:y.array(y.string()).optional()},acceptsUndefined:!0};function de(t,r,i,o){return Object.freeze({id:`dsh-backup#backupPanel/${t}`,service:"backupPanel",namespace:"backupPanel",method:t,invocation:Object.freeze({kind:"direct"}),parameters:Object.freeze(r.map(e=>Object.freeze({...e,codec:Object.freeze(e.codec)}))),...o?{cancellation:Object.freeze({parameter:"signal"})}:{},result:Object.freeze({mode:"strict",typeSymbol:`dsh-backup/types#${t}Result`,schema:i})})}var gm=Object.freeze({package:"dsh-backup",descriptors:Object.freeze([de("status",[],Pg,!1),de("backup",[Rg,fm],Ug,!0),de("verify",[Hu],Dg,!0),de("restore",[Hu,Cg,fm,Fg],Og,!0),de("setAuto",[Jg],Ng,!1),de("githubStatus",[],Zg,!1),de("githubSyncNow",[],Tg,!0),de("githubPull",[],Eg,!0),de("removeEntry",[Hu],Ag,!0),de("setGithubRepo",[Vg],Lg,!1)])});function me(t){if(!t.ok){let r=t.error,i=r&&r.message?`${r.code}: ${r.message}`:"backupPanel \u8C03\u7528\u5931\u8D25";throw new Error(`\u64CD\u4F5C\u6CA1\u6709\u5B8C\u6210\uFF1A\u9762\u677F\u6682\u65F6\u8FDE\u4E0D\u4E0A\u540E\u53F0\uFF0C\u8BF7\u91CD\u8BD5\uFF1B\u82E5\u53CD\u590D\u51FA\u73B0\u8BF7\u91CD\u542F dsh web\uFF08\u6280\u672F\u7EC6\u8282: ${i}\uFF09`)}return t.value}function Mg(t){t.effect(()=>t.locale.register(Cn,{zh:lm,en:dm}),"dsh-backup: dictionaries"),t.effect(()=>mm(),"dsh-backup: stylesheet"),t.effect(()=>{let r=null,i=!0,o=!1;return(async()=>{try{r=await t.remote.$mount(gm)}catch(e){console.error("dsh-backup: backupPanel mount failed:",e)}i=!1,o&&r?.()})(),()=>{o=!0,i||r?.()}},"dsh-backup: remote contribution"),t.inject(["remote.backupPanel"],r=>{let i=r.locale.bind(Cn),o=()=>r.remote.backupPanel,e={status:async()=>me(await o().status()),backup:async(n,a)=>me(await o().backup(n,a)),verify:async n=>me(await o().verify(n)),restore:async(n,a,c,s)=>me(await o().restore(n,a,c,s)),setAuto:async n=>me(await o().setAuto(n)),githubStatus:async()=>me(await o().githubStatus()),githubSyncNow:async()=>me(await o().githubSyncNow()),githubPull:async()=>me(await o().githubPull()),removeEntry:async n=>me(await o().removeEntry(n)),setGithubRepo:async n=>me(await o().setGithubRepo(n))};r.slots.inject("settings.plugins.tab",()=>r.slots.register({name:"settings.plugins.tab",id:"backup",order:35,label:()=>i("tab"),locale:Cn,inject:()=>({panel:e})},sm))})}
|
|
454
454
|
return module.exports; } });
|
package/lib/index.js
CHANGED
|
@@ -1828,9 +1828,12 @@ export function apply(ctx, pluginConfig) {
|
|
|
1828
1828
|
// 布局与格式依据宿主内置的 @deepseek-ai/dsh-session-persistence-jsonl:
|
|
1829
1829
|
// <会话根>/--<项目目录>--/<编码后 id>/session.jsonl.zstd(compression:'none' 时为 .jsonl)
|
|
1830
1830
|
// zstd 文件是独立帧的拼接:首帧是带校验和的 SessionHeader 行,其后每个追加批一帧。
|
|
1831
|
+
// 两条硬契约都对齐宿主读端:①行级——首行 SessionHeader、seq 连续;②容器级——
|
|
1832
|
+
// 首帧解出来恰好一行 header(宿主 assertZstdHeaderFrame;外部工具把整份日志
|
|
1833
|
+
// 压成单帧时内容完整可解、宿主却拒载,行级校验覆盖不到,必须单独判)。
|
|
1831
1834
|
// Node 内置 zstd 解码器不跨帧续读,因此先按 RFC 8878 走帧字节边界
|
|
1832
1835
|
// (魔数/帧头/块序列/校验和),再逐帧解出逻辑行。检测目标正是社区高频的
|
|
1833
|
-
// "corrupt session log"灾难:并发写 seq
|
|
1836
|
+
// "corrupt session log"灾难:并发写 seq 撞号、坏帧、尾部截断、单帧重写。
|
|
1834
1837
|
const ZSTD_MAGIC = 0xfd2fb528;
|
|
1835
1838
|
const SESSION_LOG_NAMES = new Set(['session.jsonl.zstd', 'session.jsonl']);
|
|
1836
1839
|
const SCAN_SKIP_DIRS = new Set(['node_modules', '.system', '.git', 'vault']);
|
|
@@ -1848,7 +1851,9 @@ export function apply(ctx, pluginConfig) {
|
|
|
1848
1851
|
|
|
1849
1852
|
/**
|
|
1850
1853
|
* 按 RFC 8878 结构走查拼接帧的字节边界(不解压):返回 [start,end] 区间数组;
|
|
1851
|
-
* 结构不合法(截断/未知魔数/保留位/越界块)抛错。
|
|
1854
|
+
* 结构不合法(截断/未知魔数/保留位/越界块)抛错。
|
|
1855
|
+
* skippable 帧直接判非法——宿主 scanZstdFrames 拒绝一切非数据帧魔数,
|
|
1856
|
+
* 这类文件宿主整体拒载,容忍它们会造成「doctor 报 OK、宿主编译系统读不了」。
|
|
1852
1857
|
*/
|
|
1853
1858
|
function walkZstdFrames(buf) {
|
|
1854
1859
|
const frames = [];
|
|
@@ -1887,12 +1892,7 @@ export function apply(ctx, pluginConfig) {
|
|
|
1887
1892
|
frames.push([off, pos]);
|
|
1888
1893
|
off = pos;
|
|
1889
1894
|
} else if (magic >= 0x184d2a50 && magic <= 0x184d2a5f) {
|
|
1890
|
-
|
|
1891
|
-
const size = buf.readUInt32LE(off + 4);
|
|
1892
|
-
const end = off + 8 + size;
|
|
1893
|
-
if (end > buf.length) throw new Error('skippable 帧数据越界');
|
|
1894
|
-
frames.push([off, end]);
|
|
1895
|
-
off = end;
|
|
1895
|
+
throw new Error('遇 skippable 帧——宿主读端不支持任何非数据帧魔数,整个文件将被拒载');
|
|
1896
1896
|
} else {
|
|
1897
1897
|
throw new Error(`偏移 ${off} 处不是 zstd 帧魔数`);
|
|
1898
1898
|
}
|
|
@@ -1900,19 +1900,32 @@ export function apply(ctx, pluginConfig) {
|
|
|
1900
1900
|
return frames;
|
|
1901
1901
|
}
|
|
1902
1902
|
|
|
1903
|
-
/**
|
|
1903
|
+
/**
|
|
1904
|
+
* 解出 .zstd 会话日志的全部逻辑行;任一帧损坏即抛错(错误含偏移信息)。
|
|
1905
|
+
* 同时对首个数据帧做宿主容器契约判定 headerFrameOk——宿主 assertZstdHeaderFrame
|
|
1906
|
+
* 是字节精确的:首帧解出必须非空、且首个换行符恰在最后一个字节(即「恰好
|
|
1907
|
+
* 一行、以单个 \n 结尾」)。行流拼接校验覆盖不到这条(#66)。
|
|
1908
|
+
*/
|
|
1904
1909
|
function decodeZstdLog(buf) {
|
|
1905
1910
|
const parts = [];
|
|
1906
1911
|
let frameNo = 0;
|
|
1912
|
+
let headerFrameOk;
|
|
1907
1913
|
for (const [s, e] of walkZstdFrames(buf)) {
|
|
1908
1914
|
frameNo += 1;
|
|
1909
1915
|
try {
|
|
1910
|
-
|
|
1916
|
+
const out = zlib.zstdDecompressSync(buf.subarray(s, e));
|
|
1917
|
+
parts.push(out);
|
|
1918
|
+
if (headerFrameOk === undefined) {
|
|
1919
|
+
headerFrameOk = out.length > 0 && out.indexOf(10) === out.length - 1;
|
|
1920
|
+
}
|
|
1911
1921
|
} catch (err) {
|
|
1912
1922
|
throw new Error(`第 ${frameNo} 帧解压失败(${e - s}B):${String(err && err.message ? err.message : err)}`);
|
|
1913
1923
|
}
|
|
1914
1924
|
}
|
|
1915
|
-
return
|
|
1925
|
+
return {
|
|
1926
|
+
lines: Buffer.concat(parts).toString('utf8').split('\n').filter((l) => l.length > 0),
|
|
1927
|
+
headerFrameOk,
|
|
1928
|
+
};
|
|
1916
1929
|
}
|
|
1917
1930
|
|
|
1918
1931
|
/**
|
|
@@ -1993,8 +2006,15 @@ export function apply(ctx, pluginConfig) {
|
|
|
1993
2006
|
return { state: 'skipped', reason: `文件 ${Math.floor(info.size / 1048576)}MB 超过深度校验上限(${Math.floor(HASH_MAX_BYTES / 1048576)}MB),不整读入内存` };
|
|
1994
2007
|
}
|
|
1995
2008
|
let lines;
|
|
2009
|
+
let headerFrameOk;
|
|
1996
2010
|
if (f.abs.endsWith('.zstd')) {
|
|
1997
|
-
lines = decodeZstdLog(await readFile(f.abs));
|
|
2011
|
+
({ lines, headerFrameOk } = decodeZstdLog(await readFile(f.abs)));
|
|
2012
|
+
// 容器契约(#66/#1047):首帧必须解出「恰好一行 header、以单个换行
|
|
2013
|
+
// 结尾」(字节精确,对齐宿主 assertZstdHeaderFrame)。单帧重写、帧内
|
|
2014
|
+
// 多余空行、缺行尾的文件内容完整可解、行级校验全过,宿主却整体拒载。
|
|
2015
|
+
if (headerFrameOk === false) {
|
|
2016
|
+
return { state: 'bad', reason: '首帧不是恰好一行 header——违反容器契约(首帧应解出一行 SessionHeader 且以单个换行结尾;单帧重写/多余空行/缺行尾的典型形态,宿主读端会拒载)' };
|
|
2017
|
+
}
|
|
1998
2018
|
} else {
|
|
1999
2019
|
lines = (await readFile(f.abs, 'utf8')).split('\n').filter((l) => l.length > 0);
|
|
2000
2020
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiaoyuyu6420/dsh-backup",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Backup, restore, download and GitHub-sync DeepSeek Harness user data (~/.dsh): /backup, scheduled auto-backup that survives restarts, sha256 checksums, integrity verify, rotation, credential redaction with a local vault (plaintext never leaves the machine), cross-machine restore preflight with github pull, and a visual Settings panel. Cross-platform (macOS/Linux/Windows). 一键备份与恢复 DSH 数据:定时自动备份、完整性校验、凭据默认脱敏(明文只存本机 vault)、跨机恢复预检与 github pull 拉取,附 Settings 可视面板。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.24.0",
|
package/rescue/rescue.mjs
CHANGED
|
@@ -238,12 +238,9 @@ function walkZstdFrames(buf) {
|
|
|
238
238
|
frames.push([off, pos]);
|
|
239
239
|
off = pos;
|
|
240
240
|
} else if (magic >= 0x184d2a50 && magic <= 0x184d2a5f) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (end > buf.length) throw new Error('skippable 帧数据越界');
|
|
245
|
-
frames.push([off, end]);
|
|
246
|
-
off = end;
|
|
241
|
+
// 宿主 scanZstdFrames 拒绝一切非数据帧魔数——容忍 skippable 会造成
|
|
242
|
+
// 「doctor 报 OK、宿主整体拒载」(#66,与 lib/index.js 同步)
|
|
243
|
+
throw new Error('遇 skippable 帧——宿主读端不支持任何非数据帧魔数,整个文件将被拒载');
|
|
247
244
|
} else {
|
|
248
245
|
throw new Error(`偏移 ${off} 处不是 zstd 帧魔数`);
|
|
249
246
|
}
|
|
@@ -251,18 +248,32 @@ function walkZstdFrames(buf) {
|
|
|
251
248
|
return frames;
|
|
252
249
|
}
|
|
253
250
|
|
|
251
|
+
/**
|
|
252
|
+
* 解出 .zstd 会话日志的全部逻辑行;任一帧损坏即抛错。同时对首个数据帧做
|
|
253
|
+
* 宿主容器契约判定 headerFrameOk——宿主 assertZstdHeaderFrame 是字节精确的:
|
|
254
|
+
* 首帧解出必须非空、且首个换行符恰在最后一个字节(「恰好一行、单个 \n 结尾」)。
|
|
255
|
+
* 行流拼接校验覆盖不到这条(#66,与 lib/index.js 同步)。
|
|
256
|
+
*/
|
|
254
257
|
function decodeZstdLog(buf) {
|
|
255
258
|
const parts = [];
|
|
256
259
|
let frameNo = 0;
|
|
260
|
+
let headerFrameOk;
|
|
257
261
|
for (const [s, e] of walkZstdFrames(buf)) {
|
|
258
262
|
frameNo += 1;
|
|
259
263
|
try {
|
|
260
|
-
|
|
264
|
+
const out = zlib.zstdDecompressSync(buf.subarray(s, e));
|
|
265
|
+
parts.push(out);
|
|
266
|
+
if (headerFrameOk === undefined) {
|
|
267
|
+
headerFrameOk = out.length > 0 && out.indexOf(10) === out.length - 1;
|
|
268
|
+
}
|
|
261
269
|
} catch (err) {
|
|
262
270
|
throw new Error(`第 ${frameNo} 帧解压失败:${String(err && err.message ? err.message : err)}`);
|
|
263
271
|
}
|
|
264
272
|
}
|
|
265
|
-
return
|
|
273
|
+
return {
|
|
274
|
+
lines: Buffer.concat(parts).toString('utf8').split('\n').filter((l) => l.length > 0),
|
|
275
|
+
headerFrameOk,
|
|
276
|
+
};
|
|
266
277
|
}
|
|
267
278
|
|
|
268
279
|
function validateSessionLines(lines) {
|
|
@@ -329,8 +340,13 @@ async function validateSessionFile(f) {
|
|
|
329
340
|
return { state: 'skipped', reason: `文件 ${Math.floor(info.size / 1048576)}MB 超过深度校验上限` };
|
|
330
341
|
}
|
|
331
342
|
let lines;
|
|
343
|
+
let headerFrameOk;
|
|
332
344
|
if (f.abs.endsWith('.zstd')) {
|
|
333
|
-
lines = decodeZstdLog(await fs.readFile(f.abs));
|
|
345
|
+
({ lines, headerFrameOk } = decodeZstdLog(await fs.readFile(f.abs)));
|
|
346
|
+
// 容器契约(#66/#1047):首帧必须解出「恰好一行 header、单个换行结尾」
|
|
347
|
+
if (headerFrameOk === false) {
|
|
348
|
+
return { state: 'bad', reason: '首帧不是恰好一行 header——违反容器契约(首帧应解出一行 SessionHeader 且以单个换行结尾;单帧重写/多余空行/缺行尾的典型形态,宿主读端会拒载)' };
|
|
349
|
+
}
|
|
334
350
|
} else {
|
|
335
351
|
lines = (await fs.readFile(f.abs, 'utf8')).split('\n').filter((l) => l.length > 0);
|
|
336
352
|
}
|