@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/lib/index.js ADDED
@@ -0,0 +1,272 @@
1
+ /**
2
+ * dsh-onetree-log — host half.
3
+ *
4
+ * 在 DSH web 服务器上注册同源前缀路由 `/onetree-log*`:
5
+ * POST /onetree-log/upload?name=<原文件名> 原始字节流上传(非 multipart,天然
6
+ * 流式落盘,上限 512MiB)→ 解包探测登记
7
+ * POST /onetree-log/open-path {path,label} 登记服务器本地压缩包或已解压 dump 目录
8
+ * GET /onetree-log/dumps 登记列表(时间倒序)
9
+ * GET /onetree-log/dumps/<id> 详情(meta + 文件清单/分类统计/树)
10
+ * GET /onetree-log/dumps/<id>/file?path=<rel> 文件预览(文本前 256KiB;二进制探测)
11
+ * DELETE /onetree-log/dumps/<id> 删除登记(path 类不动用户原文件/目录)
12
+ *
13
+ * 信任围栏与 @iasiv5/dsh-obmc-web 一致:Host 须 loopback 或部署信任域,
14
+ * Sec-Fetch-Site 不得 cross-site,Origin 须同 Host 主机名。这不是认证——
15
+ * 认证由部署在 GUI 前面的网关(dsh-auth-caddy 等)负责。
16
+ *
17
+ * 第二阶段扩展点:在 `/onetree-log/dumps/<id>/section/<name>` 上挂
18
+ * 逐域解析器(sel/sensors/journal/…),见 lib/inventory.js 的 SECTIONS。
19
+ */
20
+ import fs from 'node:fs'
21
+ import path from 'node:path'
22
+ import { pipeline } from 'node:stream/promises'
23
+ import { Readable } from 'node:stream'
24
+ import { DumpStore } from './store.js'
25
+
26
+ export const name = '@inventec/dsh-onetree-log'
27
+ export const inject = ['webServer', 'webRuntime']
28
+
29
+ const MAX_UPLOAD_BYTES = 512 * 1024 * 1024
30
+ const PREVIEW_BYTES = 256 * 1024
31
+ const JSON_BODY_LIMIT = 8192
32
+ const TMP_ROOT = () => path.join(store.dumpsDir, '.tmp-uploads')
33
+
34
+ let store = null
35
+
36
+ function writeJson(res, status, payload) {
37
+ const body = JSON.stringify(payload)
38
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
39
+ res.end(body)
40
+ }
41
+
42
+ function readJsonBody(req) {
43
+ return new Promise((resolve, reject) => {
44
+ const chunks = []
45
+ let size = 0
46
+ req.on('data', chunk => {
47
+ size += chunk.length
48
+ if (size > JSON_BODY_LIMIT) { reject(new Error('request body too large')); req.destroy(); return }
49
+ chunks.push(chunk)
50
+ })
51
+ req.on('end', () => {
52
+ try {
53
+ resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')))
54
+ } catch {
55
+ reject(new Error('invalid JSON body'))
56
+ }
57
+ })
58
+ req.on('error', reject)
59
+ })
60
+ }
61
+
62
+ /** 上传字节流 → 临时文件(限额流式落盘,不进内存)。 */
63
+ async function saveUpload(req, destPath) {
64
+ let received = 0
65
+ const counter = new Readable().wrap(req) // 仅用于错误传播;直接流 req
66
+ const out = fs.createWriteStream(destPath)
67
+ await new Promise((resolve, reject) => {
68
+ req.on('data', chunk => {
69
+ received += chunk.length
70
+ if (received > MAX_UPLOAD_BYTES) {
71
+ reject(new Error(`上传超过 ${(MAX_UPLOAD_BYTES / 1024 / 1024).toFixed(0)}MiB 上限;超大日志请放到服务器本地后用「登记服务器路径」导入`))
72
+ req.destroy()
73
+ out.destroy()
74
+ return
75
+ }
76
+ if (!out.write(chunk)) {
77
+ req.pause()
78
+ out.once('drain', () => req.resume())
79
+ }
80
+ })
81
+ req.on('end', () => out.end(() => resolve()))
82
+ req.on('error', reject)
83
+ out.on('error', reject)
84
+ })
85
+ void counter
86
+ }
87
+
88
+ function sanitizeUploadName(raw) {
89
+ const base = path.basename(String(raw ?? ''))
90
+ if (base === '' || base === '.' || base === '..' || base.includes('/') || base.includes('\\') || base.includes('\0')) {
91
+ throw new Error('非法的上传文件名')
92
+ }
93
+ return base.slice(0, 200)
94
+ }
95
+
96
+ // ── 信任围栏(与 dsh-obmc-web 同款) ─────────────────────────────────────
97
+
98
+ function isLoopbackHostname(hostname) {
99
+ if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
100
+ const parts = hostname.split('.')
101
+ return parts.length === 4
102
+ && parts[0] === '127'
103
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
104
+ }
105
+
106
+ function trustedRequest(req, trustedHosts) {
107
+ const host = req.headers.host
108
+ if (typeof host !== 'string' || host === '') return false
109
+ let hostUrl
110
+ try { hostUrl = new URL(`http://${host}`) } catch { return false }
111
+ const trusted = (trustedHosts ?? []).some(entry => {
112
+ try {
113
+ const entryUrl = new URL(`http://${entry}`)
114
+ const withPort = entryUrl.port !== ''
115
+ return withPort
116
+ ? `${entryUrl.hostname}:${entryUrl.port}` === hostUrl.host
117
+ : entryUrl.hostname === hostUrl.hostname
118
+ } catch { return false }
119
+ })
120
+ if (!isLoopbackHostname(hostUrl.hostname) && !trusted) return false
121
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
122
+ const origin = req.headers.origin
123
+ if (origin === undefined) return true
124
+ try {
125
+ return new URL(origin).hostname === hostUrl.hostname
126
+ } catch {
127
+ return false
128
+ }
129
+ }
130
+
131
+ // ── 文件预览 ─────────────────────────────────────────────────────────────
132
+
133
+ function looksBinary(buf) {
134
+ const n = Math.min(buf.length, 2048)
135
+ for (let i = 0; i < n; i += 1) {
136
+ const b = buf[i]
137
+ if (b === 0) return true
138
+ if (b < 7 || (b > 13 && b < 32 && b !== 27)) return true
139
+ }
140
+ return false
141
+ }
142
+
143
+ function previewFile(absPath) {
144
+ const stat = fs.statSync(absPath)
145
+ if (!stat.isFile()) throw new Error('不是常规文件')
146
+ const handle = fs.openSync(absPath, 'r')
147
+ try {
148
+ const len = Math.min(stat.size, PREVIEW_BYTES)
149
+ const buf = Buffer.alloc(len)
150
+ fs.readSync(handle, buf, 0, len, 0)
151
+ if (looksBinary(buf)) {
152
+ return { binary: true, size: stat.size, truncated: stat.size > len }
153
+ }
154
+ return {
155
+ binary: false,
156
+ size: stat.size,
157
+ truncated: stat.size > len,
158
+ content: buf.toString('utf8'),
159
+ }
160
+ } finally {
161
+ fs.closeSync(handle)
162
+ }
163
+ }
164
+
165
+ // ── 路由分发 ─────────────────────────────────────────────────────────────
166
+
167
+ async function dispatch(req, res, log) {
168
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
169
+ const parts = url.pathname.split('/').filter(Boolean) // ['onetree-log', ...]
170
+ const method = req.method ?? 'GET'
171
+
172
+ // GET /onetree-log/dumps
173
+ if (parts[1] === 'dumps' && parts.length === 2) {
174
+ if (method !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
175
+ return writeJson(res, 200, { dumps: store.list() })
176
+ }
177
+
178
+ if (parts.length === 1 && parts[0] === 'onetree-log') {
179
+ if (method !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
180
+ return writeJson(res, 200, { dumps: store.list() })
181
+ }
182
+
183
+ if (parts[1] === 'dumps' && parts.length === 3) {
184
+ const id = parts[2]
185
+ if (method === 'GET') {
186
+ const detail = store.detail(id)
187
+ if (!detail) return writeJson(res, 404, { error: 'dump 不存在' })
188
+ return writeJson(res, 200, detail)
189
+ }
190
+ if (method === 'DELETE') {
191
+ return writeJson(res, store.remove(id) ? 200 : 404, { ok: true })
192
+ }
193
+ return writeJson(res, 405, { error: 'method not allowed' })
194
+ }
195
+
196
+ if (parts[1] === 'dumps' && parts[3] === 'file' && parts.length === 4) {
197
+ if (method !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
198
+ const rel = url.searchParams.get('path') ?? ''
199
+ try {
200
+ const abs = store.resolveFile(parts[2], rel)
201
+ return writeJson(res, 200, previewFile(abs))
202
+ } catch (error) {
203
+ return writeJson(res, 400, { error: error.message })
204
+ }
205
+ }
206
+
207
+ if (parts[1] === 'upload' && parts.length === 2) {
208
+ if (method !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
209
+ const name = sanitizeUploadName(url.searchParams.get('name'))
210
+ fs.mkdirSync(TMP_ROOT(), { recursive: true })
211
+ const tmp = path.join(TMP_ROOT(), `${Date.now()}-${Math.random().toString(16).slice(2)}-${name}`)
212
+ try {
213
+ await saveUpload(req, tmp)
214
+ const meta = await store.ingestArchive(tmp, { sourceKind: 'upload', label: name })
215
+ log?.info?.(`[dsh-onetree-log] ingested upload ${meta.id} (${meta.detected.score} 分)`)
216
+ return writeJson(res, 200, { ok: true, dump: { ...meta, rootDir: undefined } })
217
+ } catch (error) {
218
+ try { fs.rmSync(tmp, { force: true }) } catch { /* ignore */ }
219
+ return writeJson(res, 400, { error: error.message })
220
+ }
221
+ }
222
+
223
+ if (parts[1] === 'open-path' && parts.length === 2) {
224
+ if (method !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
225
+ try {
226
+ const body = await readJsonBody(req)
227
+ const p = String(body.path ?? '').trim()
228
+ if (p === '') return writeJson(res, 400, { error: 'path 不能为空' })
229
+ const resolved = path.resolve(p)
230
+ const stat = fs.statSync(resolved) // 不存在时抛错 → 400
231
+ const label = String(body.label ?? path.basename(resolved)).slice(0, 200)
232
+ const meta = stat.isDirectory()
233
+ ? store.ingestDir(resolved, { label })
234
+ : await store.ingestArchive(resolved, { sourceKind: 'path-archive', label })
235
+ log?.info?.(`[dsh-onetree-log] ingested path ${meta.id} (${meta.sourceKind})`)
236
+ return writeJson(res, 200, { ok: true, dump: { ...meta, rootDir: undefined } })
237
+ } catch (error) {
238
+ return writeJson(res, 400, { error: error.message })
239
+ }
240
+ }
241
+
242
+ return writeJson(res, 404, { error: 'not found' })
243
+ }
244
+
245
+ export function apply(ctx) {
246
+ store = new DumpStore()
247
+ ctx.logger?.info?.(`[dsh-onetree-log] store at ${store.dumpsDir},已登记 ${store.list().length} 个 dump`)
248
+
249
+ const trustedHostsOf = () => (Array.isArray(ctx.webRuntime?.trustedHosts) ? ctx.webRuntime.trustedHosts : [])
250
+ try {
251
+ ctx.effect(() => ctx.webServer.register({
252
+ kind: 'prefix',
253
+ path: '/onetree-log',
254
+ handler: (req, res) => {
255
+ if (!trustedRequest(req, trustedHostsOf())) {
256
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
257
+ res.end('dsh-onetree-log: forbidden')
258
+ return
259
+ }
260
+ dispatch(req, res, ctx.logger).catch(error => {
261
+ ctx.logger?.warn?.(`[dsh-onetree-log] ${req.method} ${req.url} failed: ${error.message}`)
262
+ try {
263
+ if (!res.headersSent) writeJson(res, 500, { error: error.message })
264
+ else res.end()
265
+ } catch { /* socket gone */ }
266
+ })
267
+ },
268
+ }), 'dsh-onetree-log: /onetree-log prefix')
269
+ } catch (error) {
270
+ ctx.logger?.warn?.(`[dsh-onetree-log] skipped route registration: ${error.message}`)
271
+ }
272
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * inventory.js — dump 文件清单与基础分类。
3
+ *
4
+ * 第二阶段逐域解析的基石:把 dump 根目录走一遍,产出
5
+ * - files[]:每个文件的相对路径 / 大小 / 所属 section(域)
6
+ * - stats:按 section 聚合(文件数 / 字节数),+ 总量与截断标记
7
+ * - tree:嵌套目录树(供前端渲染)
8
+ *
9
+ * section 划分与《OneTree 一键日志分析方案》P1 报告 §4.3 对齐;
10
+ * KNOWN 表是"文件名 → section"的映射,第二阶段的解析器(sel/sensors/journal…)
11
+ * 直接按 section 挂到对应文件集上,未识别文件归入 other,不丢失。
12
+ */
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+
16
+ export const SECTIONS = {
17
+ overview: { label: '概览', color: '#8b9cf7' },
18
+ sel: { label: 'SEL 事件', color: '#f87171' },
19
+ sensors: { label: '传感器', color: '#fbbf24' },
20
+ elog: { label: 'phosphor 事件', color: '#fb923c' },
21
+ journal: { label: '系统日志', color: '#60a5fa' },
22
+ inventory: { label: 'FRU/库存', color: '#34d399' },
23
+ services: { label: '服务/进程', color: '#a78bfa' },
24
+ boot: { label: '启动', color: '#f472b6' },
25
+ thermal: { label: '温度/风扇', color: '#fdba74' },
26
+ power: { label: '电源', color: '#facc15' },
27
+ network: { label: '网络', color: '#22d3ee' },
28
+ versions: { label: '版本', color: '#94a3b8' },
29
+ console: { label: '串口控制台', color: '#e879f9' },
30
+ other: { label: '其他', color: '#9ca3af' },
31
+ }
32
+
33
+ /** 文件名(basename)→ section 的已知映射。第二阶段在此表中追加即可。 */
34
+ export const KNOWN_FILES = {
35
+ // overview
36
+ 'summary.log': 'overview', 'em-system.json': 'overview', 'bmc-state.log': 'overview',
37
+ 'host-state.log': 'overview', 'chassis-state.log': 'overview', 'settings.log': 'overview',
38
+ 'uptime.log': 'overview', 'os-release': 'overview', 'timedate.log': 'overview',
39
+ // sel
40
+ 'selinfo.log': 'sel', 'selinfo': 'sel', 'ipmitool_sel_list.log': 'sel',
41
+ // sensors
42
+ 'sensorinfo.log': 'sensors', 'sensor-readings.log': 'sensors', 'hwmon.log': 'thermal',
43
+ // elog
44
+ 'elogall.log': 'elog', 'eventlog.log': 'elog',
45
+ // journal
46
+ 'journal-pretty.log': 'journal', 'journalctl.log': 'journal', 'journal.log': 'journal',
47
+ 'dmesg.log': 'journal', 'kernalringbuff.log': 'journal', 'kernelringbuff.log': 'journal',
48
+ 'kernalcmdline.log': 'journal', 'kernlcmdline.log': 'journal',
49
+ // inventory
50
+ 'fru-info.log': 'inventory', 'inventory.log': 'inventory', 'fru_info.txt': 'inventory',
51
+ // services
52
+ 'failed-services.log': 'services', 'pslist.log': 'services', 'top.log': 'services',
53
+ 'freemem.log': 'services', 'meminfo': 'services', 'cpuinfo': 'services',
54
+ 'slabinfo.log': 'services', 'softirqs.log': 'services', 'interrupts.log': 'services',
55
+ 'lsof.log': 'services', 'varfilelist.log': 'services', 'tmpfilelist.log': 'services',
56
+ 'disk-usage.log': 'services', 'mntinfo.log': 'services', 'dreport.log': 'services',
57
+ // boot
58
+ 'biospostcode.log': 'boot', 'boot-progress-info.log': 'boot', 'fw-printenv.log': 'boot',
59
+ 'bios.log': 'boot',
60
+ // thermal
61
+ 'thermal_info.log': 'thermal', 'fan_info.log': 'thermal', 'faninfo.log': 'thermal',
62
+ // power
63
+ 'power_statistics.csv': 'power', 'psu_info.log': 'power', 'psuinfo.log': 'power',
64
+ 'powerinfo.log': 'power',
65
+ // network
66
+ 'network.log': 'network', 'netstat.log': 'network', 'iproute.log': 'network',
67
+ 'ipaddr.log': 'network', 'iplink.log': 'network', 'arptable.log': 'network',
68
+ 'routeinfo.log': 'network', 'networkrouteinfo.log': 'network', 'networkconfig.log': 'network',
69
+ 'arpcntlconf.log': 'network', 'channelconfig.log': 'network', 'channelaccess.log': 'network',
70
+ // versions
71
+ 'fw-version.log': 'versions', 'app_revision.txt': 'versions', 'package_info': 'versions',
72
+ 'version_info.log': 'versions',
73
+ // console
74
+ 'obmc-console.log': 'console', 'dpu_console': 'console', 'console.log': 'console',
75
+ }
76
+
77
+ const WALK_MAX = 20_000
78
+
79
+ function classify(basename) {
80
+ if (Object.prototype.hasOwnProperty.call(KNOWN_FILES, basename)) {
81
+ return { section: KNOWN_FILES[basename], known: true }
82
+ }
83
+ return { section: 'other', known: false }
84
+ }
85
+
86
+ /**
87
+ * 构建 dump 清单。返回:
88
+ * { files: [{path, name, size, section, known}], stats, tree, truncated }
89
+ */
90
+ export function buildInventory(rootDir, { maxFiles = 10_000 } = {}) {
91
+ const files = []
92
+ const stats = { sections: {}, totalFiles: 0, totalBytes: 0, knownFiles: 0, otherFiles: 0 }
93
+ let truncated = false
94
+ let walked = 0
95
+
96
+ const bump = (section, size, known) => {
97
+ const s = stats.sections[section] ?? (stats.sections[section] = { count: 0, bytes: 0, label: SECTIONS[section]?.label ?? section })
98
+ s.count += 1
99
+ s.bytes += size
100
+ stats.totalFiles += 1
101
+ stats.totalBytes += size
102
+ if (known) stats.knownFiles += 1
103
+ else stats.otherFiles += 1
104
+ }
105
+
106
+ const walk = (dir, rel) => {
107
+ let entries
108
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
109
+ entries.sort((a, b) => a.name.localeCompare(b.name))
110
+ for (const e of entries) {
111
+ if (++walked > WALK_MAX) { truncated = true; return }
112
+ if (files.length >= maxFiles) { truncated = true; return }
113
+ const childRel = rel === '' ? e.name : `${rel}/${e.name}`
114
+ if (e.isDirectory()) {
115
+ files.push({ path: childRel, name: e.name, size: 0, dir: true, section: null, known: false })
116
+ walk(path.join(dir, e.name), childRel)
117
+ } else if (e.isFile()) {
118
+ let size = 0
119
+ try { size = fs.statSync(path.join(dir, e.name)).size } catch { /* ignore */ }
120
+ const { section, known } = classify(e.name)
121
+ files.push({ path: childRel, name: e.name, size, dir: false, section, known })
122
+ bump(section, size, known)
123
+ }
124
+ }
125
+ }
126
+ walk(rootDir, '')
127
+
128
+ return { files, stats, tree: buildTree(files), truncated }
129
+ }
130
+
131
+ /** files[] → 嵌套树(目录含 size 汇总)。 */
132
+ export function buildTree(files) {
133
+ const root = { name: '', dir: true, children: new Map(), size: 0, fileCount: 0 }
134
+ for (const f of files) {
135
+ const segments = f.path.split('/')
136
+ let node = root
137
+ for (let i = 0; i < segments.length; i += 1) {
138
+ const name = segments[i]
139
+ const isLeaf = i === segments.length - 1
140
+ if (isLeaf && !f.dir) {
141
+ node.children.set(name, { name, dir: false, size: f.size, section: f.section, known: f.known })
142
+ let up = node
143
+ while (up) { up.size += f.size; up.fileCount += 1; up = up.parent }
144
+ } else {
145
+ if (!node.children.has(name)) {
146
+ const child = { name, dir: true, children: new Map(), size: 0, fileCount: 0, parent: node }
147
+ node.children.set(name, child)
148
+ }
149
+ node = node.children.get(name)
150
+ }
151
+ }
152
+ }
153
+ const shape = node => {
154
+ if (!node.dir) {
155
+ return { name: node.name, dir: false, size: node.size, section: node.section, known: node.known }
156
+ }
157
+ const children = [...node.children.values()].sort((a, b) => {
158
+ if (a.dir !== b.dir) return a.dir ? -1 : 1
159
+ return a.name.localeCompare(b.name)
160
+ }).map(shape)
161
+ const out = { name: node.name, dir: true, size: node.size, fileCount: node.fileCount }
162
+ if (children.length > 0) out.children = children
163
+ return out
164
+ }
165
+ return shape(root)
166
+ }
package/lib/store.js ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * store.js — dump 持久化与生命周期。
3
+ *
4
+ * 目录布局(root 可用 ONETREE_LOG_DIR 覆盖):
5
+ * <root>/dumps/<id>/archive/<原始文件名> ← 上传的压缩包(open-path 登记的原地文件不复制)
6
+ * <root>/dumps/<id>/extracted/… ← 解包产物(dump 根在其中或即为其本身)
7
+ * <root>/dumps/<id>/meta.json ← 元数据
8
+ *
9
+ * id 形如 20260919-153012-ffdc_xxx(时间戳 + 归档名 slug),列表按 id 倒序即时间倒序。
10
+ * meta 记录 sourceKind(upload | path-archive | path-dir):path 类不拥有原文件,
11
+ * 删除时只清本插件目录;dir 类连 extracted 也不删(用户自己的目录)。
12
+ */
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+ import os from 'node:os'
16
+ import { randomBytes } from 'node:crypto'
17
+ import { extractAndDetect, detectDumpRoot } from './detect.js'
18
+ import { isArchive } from './extract.js'
19
+ import { buildInventory } from './inventory.js'
20
+
21
+ export function storeRoot() {
22
+ return process.env.ONETREE_LOG_DIR ?? path.join(os.homedir(), '.local', 'share', 'dsh-onetree-log')
23
+ }
24
+
25
+ const slugify = name => String(name)
26
+ .toLowerCase()
27
+ .replace(/\.(tar\.gz|tar\.zst|tgz|tzst|tar|zip)$/i, '')
28
+ .replace(/[^a-z0-9._-]+/g, '_')
29
+ .replace(/^_+|_+$/g, '')
30
+ .slice(0, 48) || 'dump'
31
+
32
+ const pad2 = n => String(n).padStart(2, '0')
33
+
34
+ function timestampId(date = new Date()) {
35
+ return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}`
36
+ + `-${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`
37
+ + `-${randomBytes(2).toString('hex')}`
38
+ }
39
+
40
+ export class DumpStore {
41
+ constructor(root) {
42
+ this.root = root ?? storeRoot()
43
+ this.dumpsDir = path.join(this.root, 'dumps')
44
+ this.cache = new Map() // id → meta
45
+ this.reload()
46
+ }
47
+
48
+ reload() {
49
+ this.cache.clear()
50
+ fs.mkdirSync(this.dumpsDir, { recursive: true })
51
+ for (const e of fs.readdirSync(this.dumpsDir, { withFileTypes: true })) {
52
+ if (!e.isDirectory()) continue
53
+ const metaPath = path.join(this.dumpsDir, e.name, 'meta.json')
54
+ try {
55
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'))
56
+ if (meta?.id === e.name) this.cache.set(meta.id, meta)
57
+ } catch { /* 半途中断的 ingest:目录在但 meta 缺失 → 视为空壳,保留待覆盖/删除 */ }
58
+ }
59
+ }
60
+
61
+ list() {
62
+ return [...this.cache.values()]
63
+ .sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0))
64
+ .map(m => ({
65
+ id: m.id, label: m.label, createdAt: m.createdAt, sourceKind: m.sourceKind,
66
+ sourcePath: m.sourcePath ?? null, archiveName: m.archiveName ?? null,
67
+ archiveSize: m.archiveSize ?? null, format: m.format ?? null,
68
+ detected: { score: m.detected?.score ?? 0, markers: m.detected?.markers ?? { strong: [], medium: [] }, hint: m.detected?.hint ?? null },
69
+ rootDir: m.rootDir,
70
+ }))
71
+ }
72
+
73
+ get(id) {
74
+ const key = String(id ?? '')
75
+ // id 直接拼进文件系统路径:只放行时间戳-hex-slug 形态的安全字符
76
+ if (!/^[0-9]{8}-[0-9]{6}-[a-f0-9]{4}-[a-z0-9._-]+$/.test(key)) return undefined
77
+ return this.cache.get(key)
78
+ }
79
+
80
+ /** ingest 完成后的完整详情:meta + 现算的文件清单(带内存缓存)。 */
81
+ detail(id) {
82
+ const meta = this.get(id)
83
+ if (!meta) return undefined
84
+ const detail = { ...meta }
85
+ try {
86
+ detail.inventory = buildInventory(meta.rootDir)
87
+ } catch (error) {
88
+ detail.inventory = { files: [], stats: { sections: {}, totalFiles: 0, totalBytes: 0, knownFiles: 0, otherFiles: 0 }, tree: null, truncated: false, error: error.message }
89
+ }
90
+ return detail
91
+ }
92
+
93
+ /**
94
+ * 登记一个压缩包(上传落盘的临时文件,或服务器本地路径)。
95
+ * upload 模式会把 archive 移进自己的目录;path 模式原地引用。
96
+ */
97
+ async ingestArchive(archivePath, { sourceKind = 'upload', label } = {}) {
98
+ const resolved = path.resolve(String(archivePath))
99
+ if (!isArchive(resolved)) {
100
+ throw new Error(`不是受支持的压缩包(.tar / .tar.gz / .tgz / .tar.zst / .tzst / .zip): ${path.basename(resolved)}`)
101
+ }
102
+ const stat = fs.statSync(resolved)
103
+ if (stat.size === 0) throw new Error('压缩包为空文件')
104
+ const base = slugify(label ?? path.basename(resolved))
105
+ const id = `${timestampId()}-${base}`
106
+ const home = path.join(this.dumpsDir, id)
107
+ fs.mkdirSync(home, { recursive: true })
108
+
109
+ try {
110
+ const detected = await extractAndDetect(resolved, home)
111
+ let archiveName = path.basename(resolved)
112
+ let archiveSize = stat.size
113
+ if (sourceKind === 'upload') {
114
+ const archiveHome = path.join(home, 'archive')
115
+ fs.mkdirSync(archiveHome, { recursive: true })
116
+ fs.renameSync(resolved, path.join(archiveHome, archiveName))
117
+ }
118
+ const meta = {
119
+ id,
120
+ label: label ?? path.basename(resolved),
121
+ createdAt: new Date().toISOString(),
122
+ sourceKind,
123
+ sourcePath: sourceKind === 'upload' ? null : resolved,
124
+ archiveName,
125
+ archiveSize,
126
+ format: null,
127
+ detected: { score: detected.score, markers: detected.markers, hint: detected.hint },
128
+ rootDir: detected.rootDir,
129
+ }
130
+ fs.writeFileSync(path.join(home, 'meta.json'), JSON.stringify(meta, null, 2))
131
+ this.cache.set(id, meta)
132
+ return meta
133
+ } catch (error) {
134
+ // 失败清理:不留半成品目录;upload 的临时文件也一并删除
135
+ fs.rmSync(home, { recursive: true, force: true })
136
+ if (sourceKind === 'upload') { try { fs.rmSync(resolved, { force: true }) } catch { /* ignore */ } }
137
+ throw error
138
+ }
139
+ }
140
+
141
+ /** 登记一个已解压的 dump 目录(不复制,原地分析;删除登记不删目录)。 */
142
+ ingestDir(dirPath, { label } = {}) {
143
+ const resolved = path.resolve(String(dirPath))
144
+ if (!fs.statSync(resolved).isDirectory()) throw new Error('路径不是目录')
145
+ const detected = detectDumpRoot(resolved)
146
+ const id = `${timestampId()}-${slugify(label ?? path.basename(resolved))}`
147
+ const home = path.join(this.dumpsDir, id)
148
+ fs.mkdirSync(home, { recursive: true })
149
+ const meta = {
150
+ id,
151
+ label: label ?? path.basename(resolved),
152
+ createdAt: new Date().toISOString(),
153
+ sourceKind: 'path-dir',
154
+ sourcePath: resolved,
155
+ archiveName: null,
156
+ archiveSize: null,
157
+ format: null,
158
+ detected: { score: detected.score, markers: detected.markers, hint: detected.hint },
159
+ rootDir: detected.rootDir,
160
+ }
161
+ fs.writeFileSync(path.join(home, 'meta.json'), JSON.stringify(meta, null, 2))
162
+ this.cache.set(id, meta)
163
+ return meta
164
+ }
165
+
166
+ /** 删除登记;只删自己拥有的数据(extracted + archive + meta)。 */
167
+ remove(id) {
168
+ const meta = this.get(id)
169
+ if (!meta) return false
170
+ // path-dir:rootDir 是用户目录,绝不能删;只清登记目录(里面只有 meta.json)
171
+ const home = path.join(this.dumpsDir, id)
172
+ if (meta.sourceKind === 'path-dir') {
173
+ fs.rmSync(home, { recursive: true, force: true })
174
+ } else {
175
+ fs.rmSync(home, { recursive: true, force: true })
176
+ }
177
+ this.cache.delete(id)
178
+ return true
179
+ }
180
+
181
+ /** 在 dump 根内安全解析相对路径(拒绝逃逸),返回绝对路径或抛错。 */
182
+ resolveFile(id, relPath) {
183
+ const meta = this.get(id)
184
+ if (!meta) throw new Error('dump 不存在')
185
+ const rel = String(relPath ?? '')
186
+ if (rel.includes('..') || rel.startsWith('/') || rel.includes('\0')) {
187
+ throw new Error('非法的文件路径')
188
+ }
189
+ const target = path.resolve(meta.rootDir, ...rel.split('/').filter(s => s !== '' && s !== '.'))
190
+ const base = path.resolve(meta.rootDir)
191
+ if (target !== base && !target.startsWith(base + path.sep)) throw new Error('非法的文件路径')
192
+ return target
193
+ }
194
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@inventec/dsh-onetree-log",
3
+ "version": "0.1.0",
4
+ "description": "AMI MegaRAC OneTree 一键日志(OpenBMC dreport dump)上传与分析框架:设置面板「OneTree 日志」上传压缩包或登记服务器本地路径,安全解包、自动探测 dump 根、文件清单分类,为第二阶段逐域解析提供内核。",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib/",
14
+ "cordis.patch.yml",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "license": "MIT",
19
+ "author": "iasiv5",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/iasiv5/dsh-onetree-log.git"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "registry": "https://registry.npmjs.org/"
27
+ },
28
+ "engines": {
29
+ "node": ">=22.15.0"
30
+ },
31
+ "keywords": [
32
+ "dsh",
33
+ "dsh-plugin",
34
+ "deepseek-harness",
35
+ "ami",
36
+ "megarac",
37
+ "onetree",
38
+ "openbmc",
39
+ "bmc",
40
+ "ffdc",
41
+ "dreport",
42
+ "log-analysis",
43
+ "settings-section"
44
+ ],
45
+ "dsh": {
46
+ "bundle": {
47
+ "patch": "./cordis.patch.yml"
48
+ },
49
+ "client": {
50
+ "platform": "web",
51
+ "inject": [
52
+ "@deepseek-ai/dsh-client-ui-layout"
53
+ ]
54
+ }
55
+ }
56
+ }