@inventec/dsh-onetree-log 0.1.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/LICENSE +21 -0
- package/README.md +100 -0
- package/cordis.patch.yml +1 -0
- package/lib/client.js +393 -0
- package/lib/detect.js +179 -0
- package/lib/extract.js +310 -0
- package/lib/index.js +272 -0
- package/lib/inventory.js +166 -0
- package/lib/store.js +194 -0
- package/package.json +56 -0
package/lib/detect.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* detect.js — OneTree 一键日志(OpenBMC dreport dump)探测。
|
|
3
|
+
*
|
|
4
|
+
* OneTree 是 AMI 的 OpenBMC 商业发行版,其"一键日志"由 phosphor-debug-collector
|
|
5
|
+
* (dreport)产出:一组扁平的 *.log 文件(ipmitool/journalctl/busctl 等命令输出)。
|
|
6
|
+
* 本模块在解包后的目录树里按"标记文件评分"定位 dump 根:
|
|
7
|
+
* - 强标记(dreport 专属):selinfo.log / sensorinfo.log / journal-pretty.log … 每个 +3
|
|
8
|
+
* - 中标记(通用 OpenBMC 采集项):dmesg.log / fru-info.log / inventory.log … 每个 +1
|
|
9
|
+
* - 分数 ≥3 判定为 OneTree dump
|
|
10
|
+
*
|
|
11
|
+
* 兼容两种常见包装:
|
|
12
|
+
* 1. 外层包直接就是 dump 文件集(根即 dump 根)
|
|
13
|
+
* 2. 外层包里嵌着内层压缩包(OEM 常见 zip 套 tar)→ 唯一压缩包时自动解一层(≤2 层)
|
|
14
|
+
*
|
|
15
|
+
* 另附 SP-X FFDC 血统标记(sulist / nvram / sensor_data 等),仅作提示——
|
|
16
|
+
* 命中说明用户上传的是老 SP-X FFDC 而非 OneTree dump,错误信息里指出来。
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { extractArchive, isArchive } from './extract.js'
|
|
21
|
+
|
|
22
|
+
export const STRONG_MARKERS = [
|
|
23
|
+
'selinfo.log', 'sensorinfo.log', 'sensor-readings.log', 'journal-pretty.log',
|
|
24
|
+
'dreport.log', 'dreport_cmdfailure.log', 'dreport.cfg', 'elogall.log',
|
|
25
|
+
'biospostcode.log', 'obmc-console.log', 'journalctl.log',
|
|
26
|
+
]
|
|
27
|
+
export const MEDIUM_MARKERS = [
|
|
28
|
+
'dmesg.log', 'fru-info.log', 'inventory.log', 'fw-version.log', 'fw-printenv.log',
|
|
29
|
+
'failed-services.log', 'pslist.log', 'top.log', 'hwmon.log', 'summary.log',
|
|
30
|
+
'em-system.json', 'bmc-state.log', 'host-state.log', 'chassis-state.log',
|
|
31
|
+
'cpuinfo', 'meminfo', 'uptime.log', 'network.log', 'netstat.log', 'iproute.log',
|
|
32
|
+
'kernalringbuff.log', 'kernelringbuff.log', 'bios.log', 'boot-progress-info.log',
|
|
33
|
+
]
|
|
34
|
+
export const SPX_MARKERS = [
|
|
35
|
+
'sulist', 'nvram', 'sensor_data', 'sel_data', 'all_sel_info', 'ipmi_log',
|
|
36
|
+
'confblob', 'ami_info', 'bmc_data',
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
const WALK_MAX_ENTRIES = 20_000
|
|
40
|
+
const NESTED_DEPTH = 2
|
|
41
|
+
|
|
42
|
+
function listDirSafe(dir) {
|
|
43
|
+
try {
|
|
44
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
45
|
+
} catch {
|
|
46
|
+
return []
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 扫描目录树,返回 { perDir: Map<dir, {score, strong[], medium[], spx[]}>, files: [{dir,name,size}] }。
|
|
52
|
+
*/
|
|
53
|
+
function scanTree(rootDir) {
|
|
54
|
+
const perDir = new Map()
|
|
55
|
+
const files = []
|
|
56
|
+
let entries = 0
|
|
57
|
+
const walk = dir => {
|
|
58
|
+
for (const e of listDirSafe(dir)) {
|
|
59
|
+
if (++entries > WALK_MAX_ENTRIES) return
|
|
60
|
+
const full = path.join(dir, e.name)
|
|
61
|
+
if (e.isDirectory()) {
|
|
62
|
+
walk(full)
|
|
63
|
+
} else if (e.isFile()) {
|
|
64
|
+
const known = STRONG_MARKERS.includes(e.name) ? 'strong'
|
|
65
|
+
: MEDIUM_MARKERS.includes(e.name) ? 'medium'
|
|
66
|
+
: SPX_MARKERS.includes(e.name.toLowerCase()) ? 'spx' : null
|
|
67
|
+
let size = 0
|
|
68
|
+
try { size = fs.statSync(full).size } catch { /* ignore */ }
|
|
69
|
+
files.push({ dir, name: e.name, size, known })
|
|
70
|
+
if (known) {
|
|
71
|
+
let slot = perDir.get(dir)
|
|
72
|
+
if (!slot) { slot = { score: 0, strong: [], medium: [], spx: [] }; perDir.set(dir, slot) }
|
|
73
|
+
slot[known === 'spx' ? 'spx' : known].push(e.name)
|
|
74
|
+
if (known === 'strong') slot.score += 3
|
|
75
|
+
else if (known === 'medium') slot.score += 1
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
walk(rootDir)
|
|
81
|
+
return { perDir, files }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 从 perDir 里选 dump 根:分数最高者;平分取最深(最贴近文件集本身)。 */
|
|
85
|
+
function pickRoot(rootDir, perDir) {
|
|
86
|
+
let best = null
|
|
87
|
+
for (const [dir, slot] of perDir) {
|
|
88
|
+
if (slot.score < 3) continue
|
|
89
|
+
const depth = dir.split(path.sep).length
|
|
90
|
+
if (best === null || slot.score > best.slot.score
|
|
91
|
+
|| (slot.score === best.slot.score && depth > best.depth)) {
|
|
92
|
+
best = { dir, slot, depth }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return best
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function topEntries(dir, limit = 15) {
|
|
99
|
+
try {
|
|
100
|
+
return fs.readdirSync(dir).slice(0, limit)
|
|
101
|
+
} catch {
|
|
102
|
+
return []
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 探测 OneTree dump 根目录。
|
|
108
|
+
* 返回 { rootDir, score, markers: {strong, medium}, hint };
|
|
109
|
+
* 未识别时抛错(错误信息包含顶层内容与 SP-X 提示,便于用户判断)。
|
|
110
|
+
*/
|
|
111
|
+
export function detectDumpRoot(extractDir) {
|
|
112
|
+
const { perDir, files } = scanTree(extractDir)
|
|
113
|
+
const best = pickRoot(extractDir, perDir)
|
|
114
|
+
if (best) {
|
|
115
|
+
return {
|
|
116
|
+
rootDir: best.dir,
|
|
117
|
+
score: best.slot.score,
|
|
118
|
+
markers: { strong: best.slot.strong, medium: best.slot.medium },
|
|
119
|
+
hint: null,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const spxHit = files.filter(f => f.known === 'spx').map(f => f.name)
|
|
123
|
+
if (spxHit.length > 0) {
|
|
124
|
+
const err = new Error('这更像 AMI MegaRAC SP-X 的 FFDC 日志(未发现 OneTree/OpenBMC dreport 标记文件,但发现 ' + spxHit.slice(0, 5).join(', ') + ')。本插件第一阶段只支持 OneTree(OpenBMC dreport)一键日志。')
|
|
125
|
+
err.code = 'SPX_NOT_ONETREE'
|
|
126
|
+
throw err
|
|
127
|
+
}
|
|
128
|
+
const err = new Error('未找到 OneTree 一键日志标记文件(如 selinfo.log / journal-pretty.log / dmesg.log)。顶层内容: ' + topEntries(extractDir).join(', '))
|
|
129
|
+
err.code = 'NOT_ONETREE'
|
|
130
|
+
err.topEntries = topEntries(extractDir)
|
|
131
|
+
throw err
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 判断目录是否"只有一个压缩包值得展开"(嵌套包装场景)。
|
|
136
|
+
* 返回该压缩包路径或 null。
|
|
137
|
+
*/
|
|
138
|
+
function soleNestedArchive(extractDir, files) {
|
|
139
|
+
const archives = files.filter(f => isArchive(f.name))
|
|
140
|
+
const others = files.filter(f => !isArchive(f.name))
|
|
141
|
+
if (archives.length === 1 && others.length === 0) {
|
|
142
|
+
return path.join(archives[0].dir, archives[0].name)
|
|
143
|
+
}
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 解包 + 探测一体:处理嵌套包装(zip 套 tar 等,≤NESTED_DEPTH 层)。
|
|
149
|
+
* 返回 detectDumpRoot 的结果;每层解到 extractDir 下的子目录。
|
|
150
|
+
*/
|
|
151
|
+
export async function extractAndDetect(archivePath, extractDir, opts = {}) {
|
|
152
|
+
let currentArchive = archivePath
|
|
153
|
+
let currentDir = extractDir
|
|
154
|
+
for (let depth = 0; depth < NESTED_DEPTH; depth += 1) {
|
|
155
|
+
const innerDir = path.join(currentDir, '__extracted')
|
|
156
|
+
await extractArchive(currentArchive, innerDir, opts)
|
|
157
|
+
const { perDir, files } = scanTree(innerDir)
|
|
158
|
+
const best = pickRoot(innerDir, perDir)
|
|
159
|
+
if (best) {
|
|
160
|
+
return {
|
|
161
|
+
rootDir: best.dir,
|
|
162
|
+
score: best.slot.score,
|
|
163
|
+
markers: { strong: best.slot.strong, medium: best.slot.medium },
|
|
164
|
+
hint: depth > 0 ? `嵌套包装已自动展开 ${depth} 层` : null,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const nested = soleNestedArchive(innerDir, files)
|
|
168
|
+
if (nested === null) {
|
|
169
|
+
// 不是嵌套包装:用统一入口报错(带 SP-X 提示)
|
|
170
|
+
detectDumpRoot(innerDir)
|
|
171
|
+
return // unreachable: detectDumpRoot 必抛错
|
|
172
|
+
}
|
|
173
|
+
currentArchive = nested
|
|
174
|
+
currentDir = innerDir
|
|
175
|
+
}
|
|
176
|
+
const err = new Error(`嵌套压缩包超过 ${NESTED_DEPTH} 层,放弃展开`)
|
|
177
|
+
err.code = 'TOO_NESTED'
|
|
178
|
+
throw err
|
|
179
|
+
}
|
package/lib/extract.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extract.js — 安全解包:OneTree 一键日志压缩包 → 目录。
|
|
3
|
+
*
|
|
4
|
+
* 设计参照 openUBMC bmcstudio_log_reviewer 的 archive.ts 思路(纯 JS tar 读取器,
|
|
5
|
+
* 拒绝路径逃逸 / 符号链接 / 超限解压),并适配 OneTree 生态:
|
|
6
|
+
* - `.tar` / `.tar.gz` / `.tgz`:纯 JS 流式 tar 解析(ustar / pax / GNU longname)
|
|
7
|
+
* - `.tar.zst` / `.tzst`:Node ≥22.15 的 zlib zstd 流;无原生支持时回退系统 `zstd -dc`
|
|
8
|
+
* - `.zip`:系统 `unzip`(回退 `tar`),先列目校验再解压(execFile,无 shell)
|
|
9
|
+
*
|
|
10
|
+
* 防护:zip-slip(绝对路径 / `..` / NUL)、符号链接与硬链接直接拒绝、
|
|
11
|
+
* 单文件 1GiB / 总量 2GiB / 条目数 20k 上限,超限立即中止并抛错。
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import zlib from 'node:zlib'
|
|
16
|
+
import { execFile, spawn } from 'node:child_process'
|
|
17
|
+
import { Readable } from 'node:stream'
|
|
18
|
+
import { finished } from 'node:stream/promises'
|
|
19
|
+
|
|
20
|
+
export const ARCHIVE_EXTS = ['.tar', '.tar.gz', '.tgz', '.tar.zst', '.tzst', '.zip']
|
|
21
|
+
export const DEFAULT_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
|
|
22
|
+
export const DEFAULT_MAX_FILES = 20_000
|
|
23
|
+
const MAX_FILE_BYTES = 1024 * 1024 * 1024
|
|
24
|
+
const EXEC_OPTS = { maxBuffer: 64 * 1024 * 1024, timeout: 300_000 }
|
|
25
|
+
|
|
26
|
+
export function archiveFormat(p) {
|
|
27
|
+
const lower = String(p).toLowerCase()
|
|
28
|
+
if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) return 'tar.gz'
|
|
29
|
+
if (lower.endsWith('.tar.zst') || lower.endsWith('.tzst')) return 'tar.zst'
|
|
30
|
+
if (lower.endsWith('.tar')) return 'tar'
|
|
31
|
+
if (lower.endsWith('.zip')) return 'zip'
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isArchive(p) {
|
|
36
|
+
return archiveFormat(p) !== null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── 路径安全(zip-slip 防护) ─────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function safeJoin(destDir, name) {
|
|
42
|
+
const raw = String(name ?? '')
|
|
43
|
+
if (raw.includes('\0')) throw new Error(`检测到不安全的归档条目(含 NUL): ${raw}`)
|
|
44
|
+
if (raw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(raw)) {
|
|
45
|
+
throw new Error(`检测到不安全的归档条目(绝对路径): ${raw}`)
|
|
46
|
+
}
|
|
47
|
+
const segments = raw.split(/[\\/]+/).filter(s => s !== '' && s !== '.')
|
|
48
|
+
if (segments.includes('..')) throw new Error(`检测到不安全的归档条目(路径逃逸): ${raw}`)
|
|
49
|
+
if (segments.length === 0) throw new Error(`检测到空的归档条目名: ${raw}`)
|
|
50
|
+
if (segments.some(s => s.length > 255)) throw new Error(`检测到超长条目名: ${raw.slice(0, 80)}…`)
|
|
51
|
+
const target = path.resolve(destDir, ...segments)
|
|
52
|
+
const base = path.resolve(destDir)
|
|
53
|
+
if (target !== base && !target.startsWith(base + path.sep)) {
|
|
54
|
+
throw new Error(`检测到不安全的归档条目(越界): ${raw}`)
|
|
55
|
+
}
|
|
56
|
+
return target
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── 纯 JS 流式 tar 解析(ustar / pax / GNU longname) ─────────────────────
|
|
60
|
+
|
|
61
|
+
function parseOctal(field) {
|
|
62
|
+
const text = field.toString('latin1').replace(/[\0 ]/g, '')
|
|
63
|
+
if (text === '') return 0
|
|
64
|
+
const value = parseInt(text, 8)
|
|
65
|
+
return Number.isNaN(value) ? 0 : value
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 解析 pax 扩展头(`%d key=value\n` 记录序列),提取 path 等覆盖项。 */
|
|
69
|
+
function parsePax(buf) {
|
|
70
|
+
const out = {}
|
|
71
|
+
let offset = 0
|
|
72
|
+
while (offset < buf.length) {
|
|
73
|
+
const sp = buf.indexOf(' ', offset)
|
|
74
|
+
if (sp < 0) break
|
|
75
|
+
const len = parseInt(buf.subarray(offset, sp).toString('latin1'), 10)
|
|
76
|
+
if (!Number.isFinite(len) || len <= 0 || offset + len > buf.length) break
|
|
77
|
+
const record = buf.subarray(sp + 1, offset + len).toString('utf8')
|
|
78
|
+
const eq = record.indexOf('=')
|
|
79
|
+
if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1).replace(/\n$/, '')
|
|
80
|
+
offset += len
|
|
81
|
+
}
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const padBytesFor = size => (512 - (size % 512)) % 512
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 从 tar 字节流解包到 destDir。流式消费(for-await 天然背压),文件内容
|
|
89
|
+
* 逐段落盘,不整包进内存。truncated 归档 / 链接条目 / 越限均抛错。
|
|
90
|
+
*
|
|
91
|
+
* 状态机:header(512B 头)→ payload(size 字节)→ padding(512 对齐填充,
|
|
92
|
+
* padRemain 显式记录,未到齐不会误当下一头)→ header …
|
|
93
|
+
*/
|
|
94
|
+
export async function extractTar(source, destDir, opts = {}) {
|
|
95
|
+
const maxTotal = opts.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES
|
|
96
|
+
const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES
|
|
97
|
+
fs.mkdirSync(destDir, { recursive: true })
|
|
98
|
+
|
|
99
|
+
let buf = Buffer.alloc(0)
|
|
100
|
+
let total = 0
|
|
101
|
+
let files = 0
|
|
102
|
+
let pendingName = null // GNU 'L' 长文件名
|
|
103
|
+
let paxPath = null // pax 'x' 的 path 覆盖
|
|
104
|
+
let sawAny = false
|
|
105
|
+
let done = false
|
|
106
|
+
let payload = null // {kind,size,written,ws?,chunks?} | null=等 header
|
|
107
|
+
let padRemain = 0 // 载荷后的对齐填充剩余字节
|
|
108
|
+
const flushes = [] // 每个写流的 finished promise:返回前必须等全部落盘
|
|
109
|
+
|
|
110
|
+
const take = n => { const out = buf.subarray(0, n); buf = buf.subarray(n); return out }
|
|
111
|
+
|
|
112
|
+
/** 处理恰好一个 512B 头;返回 false 表示还差数据。 */
|
|
113
|
+
function headerOne() {
|
|
114
|
+
if (buf.length < 512) return false
|
|
115
|
+
const header = take(512)
|
|
116
|
+
if (header.every(b => b === 0)) { done = true; return false } // 结尾空块
|
|
117
|
+
sawAny = true
|
|
118
|
+
const size = parseOctal(header.subarray(124, 136))
|
|
119
|
+
const typeflag = String.fromCharCode(header[156] || 0x30)
|
|
120
|
+
let name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/s, '')
|
|
121
|
+
const magic = header.subarray(257, 263).toString('latin1')
|
|
122
|
+
if (magic.startsWith('ustar')) {
|
|
123
|
+
const prefix = header.subarray(345, 500).toString('utf8').replace(/\0.*$/s, '')
|
|
124
|
+
if (prefix !== '') name = `${prefix}/${name}`
|
|
125
|
+
}
|
|
126
|
+
if (pendingName !== null) { name = pendingName; pendingName = null }
|
|
127
|
+
if (paxPath !== null) { name = paxPath; paxPath = null }
|
|
128
|
+
|
|
129
|
+
if (typeflag === 'L') { payload = { kind: 'longname', size, written: 0, chunks: [] }; return true }
|
|
130
|
+
if (typeflag === 'K' || typeflag === 'g') { payload = { kind: 'skip', size, written: 0 }; return true }
|
|
131
|
+
if (typeflag === 'x') { payload = { kind: 'pax', size, written: 0, chunks: [] }; return true }
|
|
132
|
+
if (typeflag === '1' || typeflag === '2') {
|
|
133
|
+
throw new Error(`检测到链接条目(${typeflag === '2' ? '符号链接' : '硬链接'}),已拒绝: ${name}`)
|
|
134
|
+
}
|
|
135
|
+
if (typeflag === '5' || name.endsWith('/')) {
|
|
136
|
+
if (name !== '' && name !== './') fs.mkdirSync(safeJoin(destDir, name), { recursive: true })
|
|
137
|
+
if (++files > maxFiles) throw new Error(`归档条目数超过上限 ${maxFiles}`)
|
|
138
|
+
return true
|
|
139
|
+
}
|
|
140
|
+
if (typeflag === '0' || typeflag === '\0' || typeflag === '7') {
|
|
141
|
+
if (size > MAX_FILE_BYTES) throw new Error(`单文件超过 1GiB 上限: ${name}`)
|
|
142
|
+
total += size
|
|
143
|
+
if (total > maxTotal) throw new Error(`解压总量超过上限 ${(maxTotal / 1024 / 1024).toFixed(0)}MiB`)
|
|
144
|
+
if (++files > maxFiles) throw new Error(`归档条目数超过上限 ${maxFiles}`)
|
|
145
|
+
const target = safeJoin(destDir, name)
|
|
146
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
147
|
+
if (size === 0) { fs.writeFileSync(target, Buffer.alloc(0)); return true }
|
|
148
|
+
const ws = fs.createWriteStream(target)
|
|
149
|
+
flushes.push(finished(ws))
|
|
150
|
+
payload = { kind: 'file', size, written: 0, ws }
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
// 其余类型(设备/FIFO 等):跳过载荷
|
|
154
|
+
if (size === 0) return true
|
|
155
|
+
payload = { kind: 'skip', size, written: 0 }
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 载荷推进一档;返回 false 表示还需更多数据(pump 须退出等下一块)。 */
|
|
160
|
+
function payloadStep() {
|
|
161
|
+
if (buf.length === 0) return false
|
|
162
|
+
const need = payload.size - payload.written
|
|
163
|
+
const give = take(Math.min(need, buf.length))
|
|
164
|
+
payload.written += give.length
|
|
165
|
+
if (payload.kind === 'file') payload.ws.write(give)
|
|
166
|
+
else if (payload.kind === 'longname' || payload.kind === 'pax') payload.chunks.push(give)
|
|
167
|
+
if (payload.written < payload.size) return false
|
|
168
|
+
|
|
169
|
+
const { kind, size, ws, chunks } = payload
|
|
170
|
+
if (kind === 'file') ws.end()
|
|
171
|
+
else if (kind === 'longname') {
|
|
172
|
+
pendingName = Buffer.concat(chunks).toString('utf8').replace(/\0.*$/s, '').trim()
|
|
173
|
+
} else if (kind === 'pax') {
|
|
174
|
+
const overrides = parsePax(Buffer.concat(chunks))
|
|
175
|
+
if (typeof overrides.path === 'string' && overrides.path !== '') paxPath = overrides.path
|
|
176
|
+
}
|
|
177
|
+
payload = null
|
|
178
|
+
padRemain = padBytesFor(size)
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let pumpIters = 0
|
|
183
|
+
function pump() {
|
|
184
|
+
for (;;) {
|
|
185
|
+
if (++pumpIters > 5_000_000) {
|
|
186
|
+
throw new Error(`tar 解析状态机失控(迭代超限):state=${payload?.kind ?? 'header'} written=${payload?.written} size=${payload?.size} padRemain=${padRemain} bufLen=${buf.length} done=${done}`)
|
|
187
|
+
}
|
|
188
|
+
if (done) return
|
|
189
|
+
if (padRemain > 0) {
|
|
190
|
+
if (buf.length === 0) return
|
|
191
|
+
const eat = Math.min(padRemain, buf.length)
|
|
192
|
+
take(eat)
|
|
193
|
+
padRemain -= eat
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
if (payload === null) {
|
|
197
|
+
if (!headerOne()) return
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
if (!payloadStep()) return
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let backpressure = null
|
|
205
|
+
for await (const chunk of source) {
|
|
206
|
+
if (done) break
|
|
207
|
+
buf = buf.length ? Buffer.concat([buf, chunk]) : chunk
|
|
208
|
+
try {
|
|
209
|
+
pump()
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (payload?.kind === 'file') payload.ws.destroy()
|
|
212
|
+
throw error
|
|
213
|
+
}
|
|
214
|
+
while (backpressure) { await backpressure; backpressure = null }
|
|
215
|
+
if (payload?.kind === 'file') {
|
|
216
|
+
const ws = payload.ws
|
|
217
|
+
if (ws.writableLength > 1 << 20) {
|
|
218
|
+
backpressure = new Promise(resolve => ws.once('drain', resolve))
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (payload !== null) {
|
|
223
|
+
if (payload.kind === 'file') payload.ws.end()
|
|
224
|
+
throw new Error('归档在条目载荷中途结束(文件损坏或下载不完整)')
|
|
225
|
+
}
|
|
226
|
+
if (!sawAny) throw new Error('不是有效的 tar 归档(未发现任何条目头)')
|
|
227
|
+
// 所有写流真正落盘后才算解包完成(end() 只入队,flush 是异步的)
|
|
228
|
+
await Promise.all(flushes)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── zip(系统工具,execFile 无 shell) ─────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
function execFilePromise(cmd, args) {
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
execFile(cmd, args, EXEC_OPTS, (error, stdout, stderr) => {
|
|
236
|
+
if (error) reject(Object.assign(error, { stderr: String(stderr || '') }))
|
|
237
|
+
else resolve(String(stdout))
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function assertSafeEntryNames(listing) {
|
|
243
|
+
for (const raw of listing.split('\n')) {
|
|
244
|
+
const name = raw.trim()
|
|
245
|
+
if (!name) continue
|
|
246
|
+
if (name.startsWith('/') || name.split(/[\\/]+/).includes('..') || name.includes('\0')) {
|
|
247
|
+
throw new Error(`检测到不安全的归档条目(路径逃逸): ${name}`)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function extractZip(archivePath, destDir) {
|
|
253
|
+
fs.mkdirSync(destDir, { recursive: true })
|
|
254
|
+
let listing
|
|
255
|
+
try {
|
|
256
|
+
listing = await execFilePromise('unzip', ['-Z1', archivePath])
|
|
257
|
+
} catch (eUnzip) {
|
|
258
|
+
try {
|
|
259
|
+
listing = await execFilePromise('tar', ['-tf', archivePath])
|
|
260
|
+
} catch {
|
|
261
|
+
throw new Error(`无法读取 zip 包(需要系统 unzip 或 tar): ${eUnzip.message}`)
|
|
262
|
+
}
|
|
263
|
+
assertSafeEntryNames(listing)
|
|
264
|
+
await execFilePromise('tar', ['-xf', archivePath, '-C', destDir])
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
assertSafeEntryNames(listing)
|
|
268
|
+
await execFilePromise('unzip', ['-o', '-q', archivePath, '-d', destDir])
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── 入口 ─────────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
/** zlib 是否带原生 zstd 流(Node ≥22.15)。 */
|
|
274
|
+
function zstdDecodeStream(archivePath) {
|
|
275
|
+
if (typeof zlib.createZstdDecompress === 'function') return null // 由调用方 pipe
|
|
276
|
+
// 回退:系统 zstd -dc 的 stdout 包成 Readable
|
|
277
|
+
const child = spawn('zstd', ['-dc', String(archivePath)], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
278
|
+
child.stderr.resume()
|
|
279
|
+
child.on('error', () => {}); // 流错误经 stdout 'error' 传出
|
|
280
|
+
return Readable.from(child.stdout)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 解包 OneTree 一键日志压缩包到 destDir(应为空目录)。
|
|
285
|
+
* 返回 { format };不支持的扩展名 / 解包失败 / 安全违规均抛错。
|
|
286
|
+
*/
|
|
287
|
+
export async function extractArchive(archivePath, destDir, opts = {}) {
|
|
288
|
+
const format = archiveFormat(archivePath)
|
|
289
|
+
if (format === null) {
|
|
290
|
+
throw new Error(`不支持的压缩格式(支持 ${ARCHIVE_EXTS.join(' / ')}): ${path.basename(archivePath)}`)
|
|
291
|
+
}
|
|
292
|
+
if (format === 'zip') {
|
|
293
|
+
await extractZip(String(archivePath), destDir)
|
|
294
|
+
return { format }
|
|
295
|
+
}
|
|
296
|
+
let stream
|
|
297
|
+
if (format === 'tar.zst') {
|
|
298
|
+
const fallback = zstdDecodeStream(archivePath)
|
|
299
|
+
if (fallback) {
|
|
300
|
+
stream = fallback
|
|
301
|
+
} else {
|
|
302
|
+
stream = fs.createReadStream(String(archivePath)).pipe(zlib.createZstdDecompress())
|
|
303
|
+
}
|
|
304
|
+
} else {
|
|
305
|
+
stream = fs.createReadStream(String(archivePath))
|
|
306
|
+
if (format === 'tar.gz') stream = stream.pipe(zlib.createGunzip())
|
|
307
|
+
}
|
|
308
|
+
await extractTar(stream, destDir, opts)
|
|
309
|
+
return { format }
|
|
310
|
+
}
|